mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add discovery-led service assurance onboarding
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Infrastructure-Aware Service Assurance Plan
|
||||
|
||||
Last updated: 2026-08-30
|
||||
Status: ACCEPTED — SLICES A AND B IMPLEMENTED
|
||||
Status: ACCEPTED — SLICES A, B, AND C IMPLEMENTED
|
||||
Governance surfaces:
|
||||
|
||||
- `status.json.coverage_gaps.infrastructure-aware-service-assurance`
|
||||
@@ -228,7 +228,7 @@ Implementation record (2026-08-30): Slice A is delivered through the
|
||||
monitoring-owned categorical history store and rollups, server-authored
|
||||
configuration revisions and remote receipt timeline, the bounded
|
||||
`/api/availability-history` batch contract, and the URL-owned Availability
|
||||
fleet presentation. Its release proofs live in the owner contract. Slices C
|
||||
fleet presentation. Its release proofs live in the owner contract. Slices D
|
||||
through H remain ordered future work; acceptance of this product lane does not
|
||||
imply that deferred breadth is already delivered.
|
||||
|
||||
@@ -269,7 +269,7 @@ re-entry. Current status and unified-resource facets preserve transport
|
||||
reachability separately from typed application correctness while the overall
|
||||
result remains the alert and history outcome. Legacy targets without an
|
||||
explicit contract retain their previous HEAD-with-bounded-GET-fallback
|
||||
semantics. Slices C through H remain ordered future work.
|
||||
semantics. Slices D through H remain ordered future work.
|
||||
|
||||
### Slice C: Discovery-Led Assurance Onboarding
|
||||
|
||||
@@ -293,6 +293,19 @@ Exit conditions:
|
||||
- ambiguous identity or endpoint evidence fails to a review state rather than
|
||||
attaching to a guessed resource.
|
||||
|
||||
Implementation record (2026-08-30): Slice C is delivered through the existing
|
||||
Discovery suggestion and availability-target contracts. Each bounded
|
||||
HTTP/HTTPS/TCP proposal carries a normalized evidence fingerprint; dismissals
|
||||
persist only for that exact evidence and stale disposition writes fail with a
|
||||
conflict. The canonical resource drawer previews inferred endpoint and
|
||||
application behavior separately from operator-controlled name, cadence, and
|
||||
observation location, offers an unsaved test, detects equivalent attached or
|
||||
standalone endpoints, and creates one explicitly enabled availability target
|
||||
with the drawer's canonical resource ID only after the operator chooses the
|
||||
activation action. A machine-scoped queue supports bulk review and
|
||||
evidence-bound dismiss/restore without bulk activation or guessed resource
|
||||
attachment. Slices D through H remain ordered future work.
|
||||
|
||||
### Slice D: Multi-Location Delivery Evidence
|
||||
|
||||
Evolve assigned execution from a remote-check option into named observation
|
||||
|
||||
@@ -242,6 +242,7 @@ after persistence.
|
||||
2b. `internal/api/chartapi/service.go`
|
||||
2c. `internal/api/chartapi/types.go`
|
||||
3. `internal/api/discovery_handlers.go`
|
||||
3a. `frontend-modern/src/api/discovery.ts`
|
||||
4. `internal/api/alerting/alerts.go`
|
||||
4a. `internal/api/attention_handlers.go`
|
||||
4b. `internal/api/attention_receipts.go`
|
||||
@@ -8788,6 +8789,16 @@ as a `network-endpoint`. Browser callers may test unsaved or saved targets, but
|
||||
the persisted target list remains owned by `/api/availability-targets` and
|
||||
must be managed from `/settings/monitoring/availability`, not reconstructed
|
||||
from resource snapshots or monitored-system counts.
|
||||
The discovery API additively carries a read-only
|
||||
`suggested_availability_probe` with an evidence fingerprint plus the matching
|
||||
dismissed fingerprint. Collection responses include that bounded proposal so
|
||||
machine-scoped review does not fan out detail reads. `PUT
|
||||
/api/discovery/{type}/{target}/{id}/availability-proposal` accepts only
|
||||
`dismissed` or `reviewable` plus the exact current evidence fingerprint, uses
|
||||
`monitoring:write`, rejects stale fingerprints with `409`, and never creates
|
||||
an availability target. Approval continues through the existing explicit
|
||||
`POST /api/availability-targets` contract, preserving one execution and
|
||||
management surface.
|
||||
Remote probe rows preserve the agent-authored check time as observation
|
||||
metadata, but API freshness and `lastSeenAt` projections use the server-authored
|
||||
receipt time supplied by monitoring. API consumers must not substitute the
|
||||
|
||||
@@ -547,6 +547,10 @@ cleanup so readers cannot retain orphaned runtime or alert projections.
|
||||
31a. `internal/availabilityprobe/probe.go`
|
||||
31b. `internal/config/availability.go`
|
||||
31c. `pkg/tlsutil/certificate.go`
|
||||
31d. `internal/servicediscovery/availability_suggestion.go`
|
||||
31e. `internal/servicediscovery/types.go`
|
||||
31f. `internal/servicediscovery/store.go`
|
||||
31g. `internal/servicediscovery/service.go`
|
||||
32. `internal/monitoring/scheduler.go`
|
||||
33. `internal/monitoring/docker_detection.go`
|
||||
34. `internal/monitoring/monitor_polling_containers.go`
|
||||
@@ -1480,6 +1484,16 @@ evidence envelopes, telemetry, logs, or Patrol context. Execution-defined
|
||||
contract edits use the existing server-authored configuration revision so
|
||||
history visibly breaks at the new proof definition instead of blending unlike
|
||||
checks.
|
||||
Discovery-led assurance proposals remain monitoring-owned read evidence until
|
||||
an operator explicitly creates a canonical availability target. The
|
||||
`internal/servicediscovery` suggestion contract derives only bounded
|
||||
HTTP/HTTPS/TCP candidates, normalizes each candidate into a stable evidence
|
||||
fingerprint, and persists dismissal against that exact fingerprint. Equivalent
|
||||
rediscovery retains a dismissal; a material endpoint, service identity, or
|
||||
inference-reason change clears it. Disposition writes reject stale
|
||||
fingerprints, and neither discovery refresh nor dismissal may create, enable,
|
||||
schedule, or execute a network check. The explicit availability-target create
|
||||
path remains the sole activation boundary.
|
||||
HTTPS checks also author one canonical certificate observation from the same
|
||||
probe execution. `internal/availabilityprobe` captures the presented leaf and
|
||||
`pkg/tlsutil/certificate.go` derives subject, issuer, SANs, SHA-256 fingerprint,
|
||||
|
||||
@@ -5617,6 +5617,7 @@
|
||||
"internal/fleethealth/",
|
||||
"internal/maintenancesentinel/",
|
||||
"internal/monitoring/",
|
||||
"internal/servicediscovery/",
|
||||
"internal/storagehealth/",
|
||||
"internal/truenas/",
|
||||
"pkg/diskinventory/",
|
||||
@@ -5667,6 +5668,22 @@
|
||||
],
|
||||
"require_explicit_path_policy_coverage": true,
|
||||
"path_policies": [
|
||||
{
|
||||
"id": "service-discovery-assurance-runtime",
|
||||
"label": "discovery-led service assurance proposal proof",
|
||||
"match_prefixes": [
|
||||
"internal/servicediscovery/"
|
||||
],
|
||||
"match_files": [],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/discovery_handlers_test.go",
|
||||
"internal/servicediscovery/availability_suggestion_test.go",
|
||||
"internal/servicediscovery/service_test.go",
|
||||
"internal/servicediscovery/store_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "maintenance-verification-runtime",
|
||||
"label": "post-maintenance verification monitoring proof",
|
||||
@@ -7677,6 +7694,8 @@
|
||||
"internal/unifiedresources/"
|
||||
],
|
||||
"owned_files": [
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx",
|
||||
"frontend-modern/src/components/Discovery/availabilityProposalModel.ts",
|
||||
"frontend-modern/src/components/Discovery/discoveryReadiness.ts",
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts",
|
||||
@@ -7878,6 +7897,8 @@
|
||||
"label": "unified resource consumer proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx",
|
||||
"frontend-modern/src/components/Discovery/availabilityProposalModel.ts",
|
||||
"frontend-modern/src/components/Discovery/discoveryReadiness.ts",
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts",
|
||||
@@ -7988,6 +8009,8 @@
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx",
|
||||
"frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts",
|
||||
"frontend-modern/src/components/Discovery/__tests__/useDiscoveryFeatureAvailability.test.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts",
|
||||
"frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx",
|
||||
|
||||
@@ -215,6 +215,8 @@ about the same disk cannot diverge.
|
||||
68. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
|
||||
69. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
|
||||
69a. `frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts`
|
||||
69b. `frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx`
|
||||
69c. `frontend-modern/src/components/Discovery/availabilityProposalModel.ts`
|
||||
70. `frontend-modern/src/utils/agentResources.ts`
|
||||
71. `frontend-modern/src/utils/canonicalResourceTypes.ts`
|
||||
72. `frontend-modern/src/utils/resourceBadgePresentation.ts`
|
||||
@@ -3804,6 +3806,15 @@ continue to use the operator-saved web-interface URL. Version, config-path,
|
||||
port, and endpoint facts surfaced outside the Discovery sub-tab must be
|
||||
labelled as Discovery-observed so API-owned resource facts and command-derived
|
||||
facts remain distinguishable.
|
||||
Availability proposals are the bounded exception that can become active
|
||||
resource evidence only through explicit review. The resource drawer supplies
|
||||
the canonical resource ID; the proposal surface previews inferred endpoint and
|
||||
behavior separately from operator-controlled name, cadence, and observation
|
||||
location; and the final action posts one enabled target with that exact
|
||||
`linkedResourceId`. Equivalent attached or standalone endpoints are detected
|
||||
before creation. Machine-scoped review may dismiss or restore several service
|
||||
proposals, but an item without the current canonical resource context remains
|
||||
review-only rather than attaching by hostname, address, or display name.
|
||||
That label must be visible through the shared Discovery provenance marker on
|
||||
compact cards, suggested URL panels, and other out-of-tab Discovery values
|
||||
rather than buried in helper text or inferred from the tab where the operator
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "34849268e58652489d2a359886fb7fdd5bddd959",
|
||||
"verified_at": "2026-08-30T16:33:41Z",
|
||||
"base_sha": "65bec55229d7b0ce94115740c8e45f19481f25c9",
|
||||
"verified_at": "2026-08-30T17:24:54Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/availabilityTargets.ts",
|
||||
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
|
||||
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
"frontend-modern/src/api/discovery.ts",
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx",
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
|
||||
"frontend-modern/src/components/Discovery/availabilityProposalModel.ts",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
|
||||
"frontend-modern/src/types/discovery.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/availabilityTargets.ts": "e8eba84f95617889782888152ef55fd92668071d35873e622de3a4fb0f39fee5",
|
||||
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx": "0f498ccc635f02f4d64848995bc3bb9a1fc4cf060812d0f66fa2a918976c25a8",
|
||||
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx": "8f47a84f61d5ea083743a7945803adc85c7495b67bd1d97df807ec91750a2330",
|
||||
"frontend-modern/src/types/resource.ts": "a524ae7b84ad2400e2a7cf1fab213824f3a9335cdadb9f42f1d73a5a0b21978f"
|
||||
"frontend-modern/src/api/discovery.ts": "ab530402b9f781725360f2f3a493d249e5e2460fc7b000fecd071ea482245b91",
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "fd730057d1ce530737d4db85406ab373300e7cb80aff08a3cebb1997703feaea",
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "34d9bbe317f04e03a475c7f008a09cb0aec7c55d6cc16111887ad1084173a83b",
|
||||
"frontend-modern/src/components/Discovery/availabilityProposalModel.ts": "3b4836202ccd7ea3e3e5a77984066d0992baa1f2aca1ab939d6c646e00de8e73",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "8fe05c6c687fae58871b7cd169d571e6e4e20e0f3658a919f1a4bd1303fee8c6",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "2da737d42e57b846e2f38872114b352dcf2e19d6f5be06ad5ba394f8d39abf3c",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "b4d13cc0b945e15d73f0b5d3cc5f07d7081728c1b81673566c7b65af9e444938",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "7516b22879ff63eca143e1b76f18bc1b86602ddb20d9fd6af74be01a529a6ab5",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "6b43a190bab1a666a41395edfe8e4dda6fa0554dd276121155148a846accf781",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "5d1125eddecc8a9d543c7497af4a1ba67032a11af6402aae22265a7bc10aa7e5",
|
||||
"frontend-modern/src/types/discovery.ts": "4831b27ebf6577411f634f8bad01c7735e47bc4d212f8d4ad29790a68e4cf251"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/monitoring/availability?add=target"
|
||||
"/docker/overview?q=uptime-kuma"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -29,18 +43,17 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"new HTTP availability check with the explicit application-response contract",
|
||||
"GET contract with text assertion and reachable endpoint whose application assertion failed",
|
||||
"GET contract with reachable endpoint and passing application assertion",
|
||||
"POST contract with request body, bearer authentication, and operator header controls",
|
||||
"phone-width failure and success feedback positioned above the sticky action footer"
|
||||
"discovered Uptime Kuma service with an evidence-bound inactive verification proposal",
|
||||
"operator-edited check name and five-minute cadence with Pulse server observation",
|
||||
"safe proposal test reporting an unresolved discovered hostname without creating a check",
|
||||
"machine-wide review listing HTTP service proposals without guessing canonical attachments",
|
||||
"desktop and phone-width proposal card and machine-review dialog layouts"
|
||||
],
|
||||
"interactions": [
|
||||
"changed probe type from ICMP to HTTP",
|
||||
"changed request method between GET and POST",
|
||||
"selected bearer authentication and added then removed an operator header",
|
||||
"entered an endpoint and text assertion and ran test-before-save",
|
||||
"confirmed transport-reachable application failure and full contract success messages",
|
||||
"verified the contract controls and feedback at desktop and phone widths"
|
||||
"opened a Docker workload drawer, Manage, Access, and embedded Discovery analysis",
|
||||
"changed the operator-owned name and interval while preserving the inferred endpoint",
|
||||
"ran Test proposal and confirmed failure feedback remained inside the proposal",
|
||||
"opened and closed Review machine suggestions and inspected the scrollable service list",
|
||||
"verified the proposal card and machine-review dialog at desktop and phone widths"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
listDiscoveriesByType,
|
||||
runDiscoveryRefresh,
|
||||
triggerDiscovery,
|
||||
updateAvailabilityProposal,
|
||||
} from '@/api/discovery';
|
||||
import { apiFetch } from '@/utils/apiClient';
|
||||
|
||||
@@ -20,6 +21,50 @@ describe('discovery api', () => {
|
||||
apiFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('records a proposal disposition against the exact evidence fingerprint', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'app-container:host-1:grafana',
|
||||
resource_type: 'app-container',
|
||||
resource_id: 'grafana',
|
||||
target_id: 'host-1',
|
||||
suggested_availability_probe: {
|
||||
protocol: 'http',
|
||||
address: '10.0.0.8',
|
||||
port: 3000,
|
||||
service_name: 'Grafana',
|
||||
reason: 'service default: grafana',
|
||||
evidence_fingerprint: 'sha256:grafana',
|
||||
},
|
||||
dismissed_availability_probe_fingerprint: 'sha256:grafana',
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await updateAvailabilityProposal(
|
||||
'app-container',
|
||||
'host-1',
|
||||
'grafana',
|
||||
'sha256:grafana',
|
||||
'dismissed',
|
||||
);
|
||||
|
||||
expect(result.dismissed_availability_probe_fingerprint).toBe('sha256:grafana');
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/discovery/docker/host-1/grafana/availability-proposal',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
evidence_fingerprint: 'sha256:grafana',
|
||||
status: 'dismissed',
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for missing agent discovery without calling detail endpoint', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
TriggerDiscoveryRequest,
|
||||
UpdateNotesRequest,
|
||||
DiscoveryInfo,
|
||||
AvailabilityProposalStatus,
|
||||
APIResourceType,
|
||||
} from '../types/discovery';
|
||||
import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget';
|
||||
|
||||
@@ -210,6 +212,34 @@ export async function updateDiscoveryNotes(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss or restore the exact availability proposal currently shown for a
|
||||
* discovery. The backend rejects stale evidence fingerprints with HTTP 409.
|
||||
* This never creates or activates a network check.
|
||||
*/
|
||||
export async function updateAvailabilityProposal(
|
||||
resourceType: ResourceType | APIResourceType,
|
||||
targetId: string,
|
||||
resourceId: string,
|
||||
evidenceFingerprint: string,
|
||||
status: AvailabilityProposalStatus,
|
||||
): Promise<ResourceDiscovery> {
|
||||
const path = `${API_BASE}/${encodeURIComponent(resolveAPIResourceType(resourceType as ResourceType))}/${encodeURIComponent(targetId)}/${encodeURIComponent(resourceId)}/availability-proposal`;
|
||||
const response = await apiFetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
evidence_fingerprint: evidenceFingerprint,
|
||||
status,
|
||||
}),
|
||||
});
|
||||
return parseRequiredAPIResponse(
|
||||
response,
|
||||
'Failed to update availability proposal',
|
||||
'Failed to parse updated availability proposal',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a discovery
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
|
||||
import MapPinIcon from 'lucide-solid/icons/map-pin';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
AvailabilityTargetsAPI,
|
||||
type AvailabilityTarget,
|
||||
type AvailabilityTestResponse,
|
||||
} from '@/api/availabilityTargets';
|
||||
import {
|
||||
listDiscoveriesByAgent,
|
||||
updateAvailabilityProposal,
|
||||
type ConnectedAgent,
|
||||
} from '@/api/discovery';
|
||||
import { ActionIconButton, Button } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import type { DiscoverySummary, ResourceDiscovery, ResourceType } from '@/types/discovery';
|
||||
import {
|
||||
buildAvailabilityTargetFromProposal,
|
||||
findAvailabilityProposalDuplicate,
|
||||
isAvailabilityProposalDismissed,
|
||||
reviewableAvailabilitySummaries,
|
||||
} from './availabilityProposalModel';
|
||||
|
||||
interface AvailabilityProposalCardProps {
|
||||
discovery: ResourceDiscovery;
|
||||
resourceType: ResourceType;
|
||||
targetId: string;
|
||||
resourceId: string;
|
||||
canonicalResourceId: string;
|
||||
connectedAgents?: readonly ConnectedAgent[];
|
||||
onDiscoveryUpdated: (discovery: ResourceDiscovery) => void;
|
||||
}
|
||||
|
||||
const endpointLabel = (proposal: ResourceDiscovery['suggested_availability_probe']): string => {
|
||||
if (!proposal) return '';
|
||||
const protocol = proposal.protocol.toLowerCase();
|
||||
const port = proposal.port ? `:${proposal.port}` : '';
|
||||
const path = proposal.path || '';
|
||||
return `${protocol}://${proposal.address}${port}${path}`;
|
||||
};
|
||||
|
||||
const expectedBehaviorLabel = (
|
||||
proposal: ResourceDiscovery['suggested_availability_probe'],
|
||||
): string =>
|
||||
proposal?.protocol === 'http' || proposal?.protocol === 'https'
|
||||
? 'GET returns HTTP 200–399'
|
||||
: 'TCP connection is accepted';
|
||||
|
||||
const testResultLabel = (result: AvailabilityTestResponse): string => {
|
||||
if (!result.success) return result.error || 'The endpoint did not satisfy this proposal.';
|
||||
if (result.application?.outcome === 'passed') {
|
||||
return result.application.statusCode
|
||||
? `Application response passed with HTTP ${result.application.statusCode}.`
|
||||
: 'Application response passed.';
|
||||
}
|
||||
return `Connection passed in ${result.latencyMillis} ms.`;
|
||||
};
|
||||
|
||||
const proposalTargetId = (summary: DiscoverySummary): string =>
|
||||
summary.agent_id || summary.target_id || summary.resource_id;
|
||||
|
||||
export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps> = (props) => {
|
||||
const proposal = createMemo(() => props.discovery.suggested_availability_probe);
|
||||
const [name, setName] = createSignal('');
|
||||
const [intervalSeconds, setIntervalSeconds] = createSignal(60);
|
||||
const [probeAgentId, setProbeAgentId] = createSignal('');
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [testing, setTesting] = createSignal(false);
|
||||
const [updatingDisposition, setUpdatingDisposition] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [testResult, setTestResult] = createSignal<AvailabilityTestResponse | null>(null);
|
||||
const [createdTarget, setCreatedTarget] = createSignal<AvailabilityTarget | null>(null);
|
||||
const [showBulkReview, setShowBulkReview] = createSignal(false);
|
||||
|
||||
const [targets, { refetch: refetchTargets }] = createResource(
|
||||
() => proposal()?.evidence_fingerprint || null,
|
||||
async () => AvailabilityTargetsAPI.list(),
|
||||
);
|
||||
const [machineDiscoveries, { refetch: refetchMachineDiscoveries }] = createResource(
|
||||
() => (showBulkReview() && props.targetId ? props.targetId : null),
|
||||
async (targetId) => listDiscoveriesByAgent(targetId),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const current = proposal();
|
||||
if (!current) return;
|
||||
setName(current.service_name || props.discovery.service_name || props.discovery.hostname);
|
||||
setTestResult(null);
|
||||
setCreatedTarget(null);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
const duplicate = createMemo(() => {
|
||||
const current = proposal();
|
||||
if (!current) return null;
|
||||
return findAvailabilityProposalDuplicate(current, props.canonicalResourceId, targets() ?? []);
|
||||
});
|
||||
const dismissed = createMemo(() => isAvailabilityProposalDismissed(props.discovery));
|
||||
const bulkItems = createMemo(() =>
|
||||
reviewableAvailabilitySummaries(machineDiscoveries()?.discoveries ?? []),
|
||||
);
|
||||
|
||||
const buildTarget = (): AvailabilityTarget | null => {
|
||||
const current = proposal();
|
||||
if (!current) return null;
|
||||
return buildAvailabilityTargetFromProposal({
|
||||
proposal: current,
|
||||
canonicalResourceId: props.canonicalResourceId,
|
||||
name: name(),
|
||||
intervalSeconds: intervalSeconds(),
|
||||
probeAgentId: probeAgentId(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const target = buildTarget();
|
||||
if (!target) return;
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
try {
|
||||
setTestResult(await AvailabilityTargetsAPI.test(target));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to test this proposal.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const target = buildTarget();
|
||||
if (!target || duplicate()?.kind === 'endpoint') return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await AvailabilityTargetsAPI.create(target);
|
||||
setCreatedTarget(created);
|
||||
await refetchTargets();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to create this check.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCurrentDisposition = async (status: 'dismissed' | 'reviewable') => {
|
||||
const current = proposal();
|
||||
if (!current) return;
|
||||
const fingerprint = current.evidence_fingerprint?.trim();
|
||||
if (!fingerprint) {
|
||||
setError('Run discovery again before changing this legacy suggestion.');
|
||||
return;
|
||||
}
|
||||
setUpdatingDisposition(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateAvailabilityProposal(
|
||||
props.resourceType,
|
||||
props.targetId,
|
||||
props.resourceId,
|
||||
fingerprint,
|
||||
status,
|
||||
);
|
||||
props.onDiscoveryUpdated(updated);
|
||||
if (showBulkReview()) await refetchMachineDiscoveries();
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : 'Failed to update this proposal review state.',
|
||||
);
|
||||
} finally {
|
||||
setUpdatingDisposition(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBulkDisposition = async (
|
||||
summary: DiscoverySummary,
|
||||
status: 'dismissed' | 'reviewable',
|
||||
) => {
|
||||
const current = summary.suggested_availability_probe;
|
||||
if (!current) return;
|
||||
const fingerprint = current.evidence_fingerprint?.trim();
|
||||
if (!fingerprint) {
|
||||
setError('Run discovery again before changing this legacy suggestion.');
|
||||
return;
|
||||
}
|
||||
setUpdatingDisposition(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateAvailabilityProposal(
|
||||
summary.resource_type,
|
||||
proposalTargetId(summary),
|
||||
summary.resource_id,
|
||||
fingerprint,
|
||||
status,
|
||||
);
|
||||
if (summary.id === props.discovery.id) props.onDiscoveryUpdated(updated);
|
||||
await refetchMachineDiscoveries();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to update the selected proposal.');
|
||||
} finally {
|
||||
setUpdatingDisposition(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={proposal()}>
|
||||
{(current) => (
|
||||
<section
|
||||
class="rounded-lg border border-blue-300 bg-blue-50/70 p-4 shadow-sm dark:border-blue-800 dark:bg-blue-950/30"
|
||||
aria-label="Suggested service verification"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-blue-950 dark:text-blue-100">
|
||||
Suggested service verification
|
||||
</h3>
|
||||
<span class="rounded-full border border-blue-300 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-700 dark:border-blue-700 dark:text-blue-300">
|
||||
Discovery evidence
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 max-w-2xl text-xs text-blue-800 dark:text-blue-200">
|
||||
Pulse inferred a useful check from this service. Review exactly what will run;
|
||||
nothing is created until you choose the active-check action below.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowBulkReview(true)}>
|
||||
Review machine suggestions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!dismissed()}
|
||||
fallback={
|
||||
<div class="mt-4 rounded-md border border-border bg-surface px-3 py-3">
|
||||
<p class="text-sm font-medium text-base-content">Suggestion dismissed</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
It stays hidden for this evidence fingerprint. A materially changed endpoint or
|
||||
service identity will make it reviewable again.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isLoading={updatingDisposition()}
|
||||
onClick={() => void updateCurrentDisposition('reviewable')}
|
||||
>
|
||||
Restore suggestion
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<div class="rounded-md border border-blue-200 bg-white/70 p-3 dark:border-blue-900 dark:bg-black/10">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
Endpoint · inferred
|
||||
</p>
|
||||
<code class="mt-1 block break-all text-xs text-base-content">
|
||||
{endpointLabel(current())}
|
||||
</code>
|
||||
</div>
|
||||
<div class="rounded-md border border-blue-200 bg-white/70 p-3 dark:border-blue-900 dark:bg-black/10">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
Expected behavior · reviewed default
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-medium text-base-content">
|
||||
{expectedBehaviorLabel(current())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-[11px] text-blue-700 dark:text-blue-300">
|
||||
Why Pulse proposed it: {current().reason}. The check will attach to this canonical
|
||||
resource, not a guessed hostname match.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<label class="block sm:col-span-1">
|
||||
<span class="text-[11px] font-medium text-base-content">
|
||||
Check name · you control
|
||||
</span>
|
||||
<input
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
|
||||
value={name()}
|
||||
onInput={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] font-medium text-base-content">
|
||||
Interval · you control
|
||||
</span>
|
||||
<select
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
|
||||
value={intervalSeconds()}
|
||||
onChange={(event) => setIntervalSeconds(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={30}>Every 30 seconds</option>
|
||||
<option value={60}>Every minute</option>
|
||||
<option value={300}>Every 5 minutes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] font-medium text-base-content">
|
||||
Observation location · you control
|
||||
</span>
|
||||
<select
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
|
||||
value={probeAgentId()}
|
||||
onChange={(event) => setProbeAgentId(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">This Pulse server</option>
|
||||
<For each={props.connectedAgents ?? []}>
|
||||
{(agent) => (
|
||||
<option value={agent.agent_id}>{agent.hostname || agent.agent_id}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={duplicate()}>
|
||||
{(match) => (
|
||||
<div class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{match().kind === 'endpoint'
|
||||
? `Already covered by “${match().target.name || match().target.address}”. Pulse will not create a duplicate endpoint check.`
|
||||
: `This resource already has “${match().target.name || match().target.address}”, but it covers a different endpoint. Review both before adding another.`}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={testResult()}>
|
||||
{(result) => (
|
||||
<div
|
||||
class={`mt-3 rounded-md border px-3 py-2 text-xs ${
|
||||
result().success
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200'
|
||||
: 'border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{testResultLabel(result())}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={createdTarget()}>
|
||||
{(created) => (
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
<CheckCircleIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Active check “{created().name}” created and attached to this resource.
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<p class="mt-3 text-xs font-medium text-red-700 dark:text-red-300">{message()}</p>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isLoading={saving()}
|
||||
disabled={Boolean(createdTarget()) || duplicate()?.kind === 'endpoint'}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
Create active check
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isLoading={testing()}
|
||||
onClick={() => void handleTest()}
|
||||
>
|
||||
Test proposal
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isLoading={updatingDisposition()}
|
||||
onClick={() => void updateCurrentDisposition('dismissed')}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Dialog
|
||||
isOpen={showBulkReview()}
|
||||
onClose={() => setShowBulkReview(false)}
|
||||
panelClass="max-w-3xl"
|
||||
ariaLabel="Review machine assurance suggestions"
|
||||
>
|
||||
<div class="max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-base-content">
|
||||
Machine assurance suggestions
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Review the services discovered through{' '}
|
||||
{props.discovery.hostname || props.targetId}. Dismissing is evidence-specific;
|
||||
creating a check still happens from its canonical resource so Pulse never
|
||||
guesses the attachment.
|
||||
</p>
|
||||
</div>
|
||||
<ActionIconButton
|
||||
label="Close machine suggestions"
|
||||
tone="muted"
|
||||
size="md"
|
||||
onClick={() => setShowBulkReview(false)}
|
||||
>
|
||||
<XIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</div>
|
||||
<div class="space-y-3 px-5 py-4">
|
||||
<Show when={machineDiscoveries.loading}>
|
||||
<div class="flex items-center gap-2 py-8 text-sm text-muted">
|
||||
<LoadingSpinner size="md" /> Loading discovered services…
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={bulkItems().length > 0}
|
||||
fallback={
|
||||
<p class="rounded-md border border-dashed border-border p-4 text-sm text-muted">
|
||||
No availability suggestions are currently available for this machine.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={bulkItems()}>
|
||||
{(item) => {
|
||||
const itemProposal = () => item.suggested_availability_probe!;
|
||||
const itemDismissed = () => isAvailabilityProposalDismissed(item);
|
||||
return (
|
||||
<article class="rounded-md border border-border bg-surface p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPinIcon
|
||||
class="h-4 w-4 shrink-0 text-blue-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="truncate text-sm font-semibold text-base-content">
|
||||
{item.service_name || item.hostname || item.resource_id}
|
||||
</h3>
|
||||
<Show when={itemDismissed()}>
|
||||
<span class="rounded-full bg-surface-hover px-2 py-0.5 text-[10px] text-muted">
|
||||
Dismissed
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<code class="mt-1 block break-all text-xs text-muted">
|
||||
{endpointLabel(itemProposal())}
|
||||
</code>
|
||||
<p class="mt-1 text-[11px] text-muted">
|
||||
{expectedBehaviorLabel(itemProposal())} · {itemProposal().reason}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={itemDismissed() ? 'secondary' : 'ghost'}
|
||||
disabled={updatingDisposition()}
|
||||
onClick={() =>
|
||||
void updateBulkDisposition(
|
||||
item,
|
||||
itemDismissed() ? 'reviewable' : 'dismissed',
|
||||
)
|
||||
}
|
||||
>
|
||||
{itemDismissed() ? 'Restore' : 'Dismiss'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">{message()}</p>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCard
|
||||
import { useDiscoveryTabState } from './useDiscoveryTabState';
|
||||
import { orderFactsByActionability } from './factOrdering';
|
||||
import { deriveCliCommand } from './cliCommand';
|
||||
import { AvailabilityProposalCard } from './AvailabilityProposalCard';
|
||||
|
||||
// Keep in sync with backend servicediscovery.DiscoveryEngineVersion. A discovery
|
||||
// with a lower (or absent) version predates the current discovery engine, so its
|
||||
@@ -46,6 +47,8 @@ interface DiscoveryTabProps {
|
||||
resourceType: ResourceType;
|
||||
agentId?: string;
|
||||
resourceId: string;
|
||||
/** Canonical unified-resource id used for an explicit assurance attachment. */
|
||||
canonicalResourceId?: string;
|
||||
hostname: string;
|
||||
/** Whether commands are enabled for this agent (from agent config) */
|
||||
commandsEnabled?: boolean;
|
||||
@@ -71,6 +74,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
isScanning,
|
||||
liveElapsedSeconds,
|
||||
notesText,
|
||||
mutateDiscovery,
|
||||
saveError,
|
||||
scanError,
|
||||
scanProgress,
|
||||
@@ -845,6 +849,18 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={d().suggested_availability_probe && props.canonicalResourceId}>
|
||||
<AvailabilityProposalCard
|
||||
discovery={d()}
|
||||
resourceType={props.resourceType}
|
||||
targetId={props.agentId || ''}
|
||||
resourceId={props.resourceId}
|
||||
canonicalResourceId={props.canonicalResourceId!}
|
||||
connectedAgents={connectedAgents()?.agents ?? []}
|
||||
onDiscoveryUpdated={mutateDiscovery}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* CLI Access */}
|
||||
<Show when={d().cli_access}>
|
||||
<div class="rounded border border-border p-3 shadow-sm">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AvailabilityTargetsAPI } from '@/api/availabilityTargets';
|
||||
import { listDiscoveriesByAgent, updateAvailabilityProposal } from '@/api/discovery';
|
||||
import type { ResourceDiscovery } from '@/types/discovery';
|
||||
import { AvailabilityProposalCard } from '../AvailabilityProposalCard';
|
||||
|
||||
vi.mock('@/api/availabilityTargets', () => ({
|
||||
AvailabilityTargetsAPI: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/discovery', () => ({
|
||||
listDiscoveriesByAgent: vi.fn(),
|
||||
updateAvailabilityProposal: vi.fn(),
|
||||
}));
|
||||
|
||||
const discovery = (overrides: Partial<ResourceDiscovery> = {}): ResourceDiscovery => ({
|
||||
id: 'docker:agent-1:grafana',
|
||||
resource_type: 'app-container',
|
||||
resource_id: 'grafana',
|
||||
target_id: 'agent-1',
|
||||
hostname: 'docker-1',
|
||||
service_type: 'grafana',
|
||||
service_name: 'Grafana',
|
||||
service_version: '12',
|
||||
category: 'monitoring',
|
||||
cli_access: '',
|
||||
facts: [],
|
||||
config_paths: [],
|
||||
data_paths: [],
|
||||
log_paths: [],
|
||||
ports: [],
|
||||
user_notes: '',
|
||||
user_secrets: {},
|
||||
confidence: 0.98,
|
||||
ai_reasoning: '',
|
||||
discovered_at: '2026-08-30T12:00:00Z',
|
||||
updated_at: '2026-08-30T12:00:00Z',
|
||||
scan_duration: 10,
|
||||
suggested_availability_probe: {
|
||||
protocol: 'http',
|
||||
address: '10.0.0.8',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
service_name: 'Grafana',
|
||||
reason: 'service default: grafana',
|
||||
evidence_fingerprint: 'sha256:grafana',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AvailabilityProposalCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AvailabilityTargetsAPI.list).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(AvailabilityTargetsAPI.create)
|
||||
.mockReset()
|
||||
.mockImplementation(async (target) => ({
|
||||
...target,
|
||||
id: 'created-check',
|
||||
}));
|
||||
vi.mocked(AvailabilityTargetsAPI.test)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
latencyMillis: 18,
|
||||
application: { outcome: 'passed', statusCode: 200 },
|
||||
});
|
||||
vi.mocked(listDiscoveriesByAgent).mockReset().mockResolvedValue({
|
||||
discoveries: [],
|
||||
total: 0,
|
||||
});
|
||||
vi.mocked(updateAvailabilityProposal)
|
||||
.mockReset()
|
||||
.mockResolvedValue(discovery({ dismissed_availability_probe_fingerprint: 'sha256:grafana' }));
|
||||
});
|
||||
|
||||
it('previews provenance and creates one explicit canonical active check', async () => {
|
||||
const onDiscoveryUpdated = vi.fn();
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
connectedAgents={[
|
||||
{
|
||||
agent_id: 'edge-1',
|
||||
hostname: 'Edge office',
|
||||
version: '6.4.0',
|
||||
platform: 'linux',
|
||||
connected_at: '2026-08-30T12:00:00Z',
|
||||
},
|
||||
]}
|
||||
onDiscoveryUpdated={onDiscoveryUpdated}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Endpoint · inferred')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expected behavior · reviewed default')).toBeInTheDocument();
|
||||
expect(screen.getByText('GET returns HTTP 200–399')).toBeInTheDocument();
|
||||
expect(screen.getByText(/nothing is created until/i)).toBeInTheDocument();
|
||||
|
||||
await fireEvent.change(screen.getByLabelText('Observation location · you control'), {
|
||||
target: { value: 'edge-1' },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Create active check' }));
|
||||
|
||||
await waitFor(() => expect(AvailabilityTargetsAPI.create).toHaveBeenCalledTimes(1));
|
||||
expect(AvailabilityTargetsAPI.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
linkedResourceId: 'resource:grafana',
|
||||
probeAgentId: 'edge-1',
|
||||
http: expect.objectContaining({
|
||||
method: 'GET',
|
||||
expectedStatusMin: 200,
|
||||
expectedStatusMax: 399,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/created and attached to this resource/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks an equivalent standalone endpoint and records evidence-bound dismissal', async () => {
|
||||
vi.mocked(AvailabilityTargetsAPI.list).mockResolvedValue([
|
||||
{
|
||||
id: 'standalone-grafana',
|
||||
name: 'Existing Grafana',
|
||||
targetKind: 'service',
|
||||
address: '10.0.0.8',
|
||||
protocol: 'http',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
const onDiscoveryUpdated = vi.fn();
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={onDiscoveryUpdated}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/already covered by “Existing Grafana”/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create active check' })).toBeDisabled();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Not now' }));
|
||||
await waitFor(() =>
|
||||
expect(updateAvailabilityProposal).toHaveBeenCalledWith(
|
||||
'app-container',
|
||||
'agent-1',
|
||||
'grafana',
|
||||
'sha256:grafana',
|
||||
'dismissed',
|
||||
),
|
||||
);
|
||||
expect(onDiscoveryUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AvailabilityTarget } from '@/api/availabilityTargets';
|
||||
import type { AvailabilityProbeSuggestion, DiscoverySummary } from '@/types/discovery';
|
||||
import {
|
||||
buildAvailabilityTargetFromProposal,
|
||||
findAvailabilityProposalDuplicate,
|
||||
isAvailabilityProposalDismissed,
|
||||
reviewableAvailabilitySummaries,
|
||||
} from '../availabilityProposalModel';
|
||||
|
||||
const proposal = (
|
||||
overrides: Partial<AvailabilityProbeSuggestion> = {},
|
||||
): AvailabilityProbeSuggestion => ({
|
||||
protocol: 'http',
|
||||
address: 'Grafana.Local.',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
service_name: 'Grafana',
|
||||
reason: 'service default: grafana',
|
||||
evidence_fingerprint: 'sha256:grafana',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const target = (overrides: Partial<AvailabilityTarget> = {}): AvailabilityTarget => ({
|
||||
id: 'existing',
|
||||
name: 'Existing Grafana check',
|
||||
targetKind: 'service',
|
||||
address: 'grafana.local',
|
||||
protocol: 'http',
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('availabilityProposalModel', () => {
|
||||
it('builds an explicit active application contract attached to the canonical resource', () => {
|
||||
expect(
|
||||
buildAvailabilityTargetFromProposal({
|
||||
proposal: proposal(),
|
||||
canonicalResourceId: 'app-container:grafana',
|
||||
name: 'Customer dashboard',
|
||||
intervalSeconds: 300,
|
||||
probeAgentId: 'edge-agent',
|
||||
}),
|
||||
).toMatchObject({
|
||||
name: 'Customer dashboard',
|
||||
targetKind: 'service',
|
||||
address: 'Grafana.Local.',
|
||||
protocol: 'http',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
linkedResourceId: 'app-container:grafana',
|
||||
enabled: true,
|
||||
pollIntervalSeconds: 300,
|
||||
probeAgentId: 'edge-agent',
|
||||
http: {
|
||||
method: 'GET',
|
||||
authentication: { type: 'none' },
|
||||
expectedStatusMin: 200,
|
||||
expectedStatusMax: 399,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates equivalent endpoints before resource-level coverage warnings', () => {
|
||||
expect(
|
||||
findAvailabilityProposalDuplicate(proposal(), 'app-container:grafana', [
|
||||
target({ linkedResourceId: 'another-resource' }),
|
||||
]),
|
||||
).toMatchObject({ kind: 'endpoint', target: { id: 'existing' } });
|
||||
|
||||
expect(
|
||||
findAvailabilityProposalDuplicate(proposal(), 'app-container:grafana', [
|
||||
target({ address: 'other.local', linkedResourceId: 'app-container:grafana' }),
|
||||
]),
|
||||
).toMatchObject({ kind: 'resource', target: { id: 'existing' } });
|
||||
});
|
||||
|
||||
it('binds dismissal to the exact evidence and sorts reviewable suggestions first', () => {
|
||||
const dismissed: DiscoverySummary = {
|
||||
id: 'docker:host:grafana',
|
||||
resource_type: 'app-container',
|
||||
resource_id: 'grafana',
|
||||
target_id: 'host',
|
||||
hostname: 'grafana',
|
||||
service_type: 'grafana',
|
||||
service_name: 'Grafana',
|
||||
service_version: '',
|
||||
category: 'monitoring',
|
||||
confidence: 0.95,
|
||||
has_user_notes: false,
|
||||
updated_at: '2026-08-30T12:00:00Z',
|
||||
suggested_availability_probe: proposal(),
|
||||
dismissed_availability_probe_fingerprint: 'sha256:grafana',
|
||||
};
|
||||
const reviewable: DiscoverySummary = {
|
||||
...dismissed,
|
||||
id: 'docker:host:redis',
|
||||
resource_id: 'redis',
|
||||
service_name: 'Redis',
|
||||
suggested_availability_probe: proposal({
|
||||
protocol: 'tcp',
|
||||
address: 'redis.local',
|
||||
port: 6379,
|
||||
service_name: 'Redis',
|
||||
evidence_fingerprint: 'sha256:redis',
|
||||
}),
|
||||
dismissed_availability_probe_fingerprint: 'sha256:old-redis',
|
||||
};
|
||||
|
||||
expect(isAvailabilityProposalDismissed(dismissed)).toBe(true);
|
||||
expect(isAvailabilityProposalDismissed(reviewable)).toBe(false);
|
||||
expect(reviewableAvailabilitySummaries([dismissed, reviewable]).map((item) => item.id)).toEqual(
|
||||
[reviewable.id, dismissed.id],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AvailabilityTarget } from '@/api/availabilityTargets';
|
||||
import type { AvailabilityProbeSuggestion, DiscoverySummary } from '@/types/discovery';
|
||||
|
||||
export const DEFAULT_DISCOVERY_ASSURANCE_INTERVAL_SECONDS = 60;
|
||||
|
||||
export type AvailabilityProposalDuplicateKind = 'endpoint' | 'resource';
|
||||
|
||||
export interface AvailabilityProposalDuplicate {
|
||||
kind: AvailabilityProposalDuplicateKind;
|
||||
target: AvailabilityTarget;
|
||||
}
|
||||
|
||||
const defaultPort = (protocol: string): number => {
|
||||
if (protocol === 'http') return 80;
|
||||
if (protocol === 'https') return 443;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const normalizedPath = (value?: string): string => {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (!trimmed || trimmed === '/') return '/';
|
||||
return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
||||
};
|
||||
|
||||
const normalizedEndpoint = (target: {
|
||||
protocol: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}): string => {
|
||||
const protocol = target.protocol.trim().toLowerCase();
|
||||
let host = target.address.trim().toLowerCase().replace(/\.$/, '');
|
||||
let port = target.port || defaultPort(protocol);
|
||||
let endpointPath = normalizedPath(target.path);
|
||||
|
||||
if ((protocol === 'http' || protocol === 'https') && /^https?:\/\//i.test(host)) {
|
||||
try {
|
||||
const parsed = new URL(host);
|
||||
host = parsed.hostname.toLowerCase().replace(/\.$/, '');
|
||||
port = Number(parsed.port) || target.port || defaultPort(protocol);
|
||||
if (!target.path) endpointPath = normalizedPath(parsed.pathname);
|
||||
} catch {
|
||||
// Keep the literal address. The canonical API validates it before save.
|
||||
}
|
||||
}
|
||||
|
||||
return [protocol, host.replace(/^\[|\]$/g, ''), String(port), endpointPath].join('|');
|
||||
};
|
||||
|
||||
export function findAvailabilityProposalDuplicate(
|
||||
proposal: AvailabilityProbeSuggestion,
|
||||
canonicalResourceId: string,
|
||||
targets: readonly AvailabilityTarget[],
|
||||
): AvailabilityProposalDuplicate | null {
|
||||
const endpoint = normalizedEndpoint(proposal);
|
||||
const exact = targets.find((target) => normalizedEndpoint(target) === endpoint);
|
||||
if (exact) return { kind: 'endpoint', target: exact };
|
||||
|
||||
const resourceId = canonicalResourceId.trim();
|
||||
const attached = resourceId
|
||||
? targets.find((target) => (target.linkedResourceId ?? '').trim() === resourceId)
|
||||
: undefined;
|
||||
return attached ? { kind: 'resource', target: attached } : null;
|
||||
}
|
||||
|
||||
export function buildAvailabilityTargetFromProposal(input: {
|
||||
proposal: AvailabilityProbeSuggestion;
|
||||
canonicalResourceId: string;
|
||||
name: string;
|
||||
intervalSeconds?: number;
|
||||
probeAgentId?: string;
|
||||
}): AvailabilityTarget {
|
||||
const { proposal } = input;
|
||||
const protocol = proposal.protocol.trim().toLowerCase() as AvailabilityTarget['protocol'];
|
||||
const isHTTP = protocol === 'http' || protocol === 'https';
|
||||
|
||||
return {
|
||||
id: '',
|
||||
name: input.name.trim() || proposal.service_name.trim() || proposal.address.trim(),
|
||||
targetKind: 'service',
|
||||
address: proposal.address.trim(),
|
||||
protocol,
|
||||
port: proposal.port || undefined,
|
||||
path: proposal.path?.trim() || undefined,
|
||||
linkedResourceId: input.canonicalResourceId.trim(),
|
||||
enabled: true,
|
||||
pollIntervalSeconds:
|
||||
input.intervalSeconds && input.intervalSeconds >= 10
|
||||
? input.intervalSeconds
|
||||
: DEFAULT_DISCOVERY_ASSURANCE_INTERVAL_SECONDS,
|
||||
timeoutMillis: 2000,
|
||||
failureThreshold: 2,
|
||||
probeAgentId: input.probeAgentId?.trim() || undefined,
|
||||
certificateExpiryWarningDays: protocol === 'https' ? 30 : undefined,
|
||||
http: isHTTP
|
||||
? {
|
||||
method: 'GET',
|
||||
authentication: { type: 'none' },
|
||||
expectedStatusMin: 200,
|
||||
expectedStatusMax: 399,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAvailabilityProposalDismissed(discovery: {
|
||||
suggested_availability_probe?: AvailabilityProbeSuggestion;
|
||||
dismissed_availability_probe_fingerprint?: string;
|
||||
}): boolean {
|
||||
const fingerprint = discovery.suggested_availability_probe?.evidence_fingerprint?.trim();
|
||||
return Boolean(
|
||||
fingerprint && fingerprint === discovery.dismissed_availability_probe_fingerprint?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function reviewableAvailabilitySummaries(
|
||||
discoveries: readonly DiscoverySummary[],
|
||||
): DiscoverySummary[] {
|
||||
return discoveries
|
||||
.filter((discovery) => Boolean(discovery.suggested_availability_probe))
|
||||
.sort((left, right) => {
|
||||
const leftDismissed = isAvailabilityProposalDismissed(left);
|
||||
const rightDismissed = isAvailabilityProposalDismissed(right);
|
||||
if (leftDismissed !== rightDismissed) return leftDismissed ? 1 : -1;
|
||||
return (left.service_name || left.hostname).localeCompare(
|
||||
right.service_name || right.hostname,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -345,6 +345,8 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
isScanning,
|
||||
liveElapsedSeconds,
|
||||
notesText,
|
||||
mutateDiscovery: mutate,
|
||||
refetchDiscovery: refetch,
|
||||
saveError,
|
||||
scanError,
|
||||
scanProgress,
|
||||
|
||||
@@ -247,6 +247,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
agentId={config().agentId}
|
||||
resourceId={config().resourceId}
|
||||
hostname={config().hostname}
|
||||
canonicalResourceId={props.resource.id}
|
||||
commandsEnabled={drawer.agentMeta()?.commandsEnabled}
|
||||
showManualRunAction
|
||||
/>
|
||||
|
||||
@@ -335,6 +335,7 @@ export const ResourceAccessDisclosure: Component<{
|
||||
agentId={config().agentId}
|
||||
resourceId={config().resourceId}
|
||||
hostname={config().hostname}
|
||||
canonicalResourceId={props.resource.id}
|
||||
commandsEnabled={props.drawer.agentMeta()?.commandsEnabled}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -150,6 +150,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
agentId={discoveryAgentId()}
|
||||
resourceId={discoveryResourceId()}
|
||||
hostname={props.guest.name}
|
||||
canonicalResourceId={props.guest.id}
|
||||
showManualRunAction
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -166,6 +166,7 @@ export const NodeDrawer: Component<NodeDrawerProps> = (props) => {
|
||||
agentId={target().agentId}
|
||||
resourceId={target().agentId}
|
||||
hostname={target().hostname || displayName()}
|
||||
canonicalResourceId={props.node.id}
|
||||
showManualRunAction
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -158,6 +158,7 @@ export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
|
||||
agentId={config().agentId}
|
||||
resourceId={config().resourceId}
|
||||
hostname={config().hostname}
|
||||
canonicalResourceId={props.host.id}
|
||||
showManualRunAction
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ResourceDiscovery {
|
||||
suggested_url_source_detail?: string;
|
||||
suggested_url_diagnostic?: string;
|
||||
suggested_availability_probe?: AvailabilityProbeSuggestion;
|
||||
dismissed_availability_probe_fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface AvailabilityProbeSuggestion {
|
||||
@@ -106,6 +107,8 @@ export interface AvailabilityProbeSuggestion {
|
||||
path?: string;
|
||||
service_name: string;
|
||||
reason: string;
|
||||
/** Present on proposals normalized by the governed review contract. */
|
||||
evidence_fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverySummary {
|
||||
@@ -126,8 +129,12 @@ export interface DiscoverySummary {
|
||||
updated_at: string;
|
||||
fingerprint?: string; // Current fingerprint
|
||||
needs_discovery?: boolean; // True if fingerprint changed
|
||||
suggested_availability_probe?: AvailabilityProbeSuggestion;
|
||||
dismissed_availability_probe_fingerprint?: string;
|
||||
}
|
||||
|
||||
export type AvailabilityProposalStatus = 'dismissed' | 'reviewable';
|
||||
|
||||
export interface DiscoveryProgress {
|
||||
resource_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'not_started';
|
||||
|
||||
@@ -924,13 +924,17 @@ func TestContract_MockDiscoveryEndpointsUseCanonicalPayloads(t *testing.T) {
|
||||
if err := json.NewDecoder(detailRec.Body).Decode(&detail); err != nil {
|
||||
t.Fatalf("decode discovery detail: %v", err)
|
||||
}
|
||||
if !discoveryContractValueEmpty(detail["suggested_url"]) {
|
||||
dockerDetail = detail
|
||||
break
|
||||
proposal, hasProposal := detail["suggested_availability_probe"].(map[string]any)
|
||||
if discoveryContractValueEmpty(detail["suggested_url"]) ||
|
||||
!hasProposal ||
|
||||
discoveryContractValueEmpty(proposal["evidence_fingerprint"]) {
|
||||
continue
|
||||
}
|
||||
dockerDetail = detail
|
||||
break
|
||||
}
|
||||
if dockerDetail == nil {
|
||||
t.Fatalf("expected mock discovery summaries to include Docker workload URL context: %#v", listBody.Discoveries)
|
||||
t.Fatalf("expected mock discovery summaries to include Docker workload URL context and an evidence-bound availability proposal: %#v", listBody.Discoveries)
|
||||
}
|
||||
for _, key := range []string{"service_name", "service_version", "config_paths", "ports", "suggested_url", "cli_access_version"} {
|
||||
value, ok := dockerDetail[key]
|
||||
@@ -938,6 +942,10 @@ func TestContract_MockDiscoveryEndpointsUseCanonicalPayloads(t *testing.T) {
|
||||
t.Fatalf("mock discovery detail missing %s: %#v", key, dockerDetail)
|
||||
}
|
||||
}
|
||||
proposal, ok := dockerDetail["suggested_availability_probe"].(map[string]any)
|
||||
if !ok || discoveryContractValueEmpty(proposal["evidence_fingerprint"]) {
|
||||
t.Fatalf("mock discovery detail missing evidence-bound availability proposal: %#v", dockerDetail)
|
||||
}
|
||||
if _, ok := dockerDetail["raw_command_output"]; ok {
|
||||
t.Fatalf("mock discovery detail exposed raw command output: %#v", dockerDetail)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -318,7 +319,7 @@ func mockDiscoveryFixtureToService(fixture *mockfixtures.DiscoveryFixture) *serv
|
||||
userSecrets[key] = value
|
||||
}
|
||||
|
||||
return &servicediscovery.ResourceDiscovery{
|
||||
discovery := &servicediscovery.ResourceDiscovery{
|
||||
ID: fixture.ID,
|
||||
ResourceType: servicediscovery.ResourceType(fixture.ResourceType),
|
||||
ResourceID: fixture.ResourceID,
|
||||
@@ -352,6 +353,8 @@ func mockDiscoveryFixtureToService(fixture *mockfixtures.DiscoveryFixture) *serv
|
||||
SuggestedURLSourceDetail: fixture.SuggestedURLSourceDetail,
|
||||
SuggestedURLDiagnostic: fixture.SuggestedURLDiagnostic,
|
||||
}
|
||||
discovery.SuggestedAvailabilityProbe = servicediscovery.SuggestAvailabilityProbe(discovery, fixture.Hostname)
|
||||
return discovery
|
||||
}
|
||||
|
||||
func mockDiscoveryFixturesToService(fixtures []*mockfixtures.DiscoveryFixture) []*servicediscovery.ResourceDiscovery {
|
||||
@@ -666,6 +669,70 @@ func (h *DiscoveryHandlers) HandleUpdateNotes(w http.ResponseWriter, r *http.Req
|
||||
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
|
||||
}
|
||||
|
||||
// HandleUpdateAvailabilityProposal handles
|
||||
// PUT /api/discovery/{type}/{target}/{id}/availability-proposal. It records a
|
||||
// review disposition only; canonical checks are still created explicitly via
|
||||
// POST /api/availability-targets.
|
||||
func (h *DiscoveryHandlers) HandleUpdateAvailabilityProposal(w http.ResponseWriter, r *http.Request) {
|
||||
if h.service == nil {
|
||||
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
|
||||
path = strings.TrimSuffix(path, "/availability-proposal")
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
|
||||
return
|
||||
}
|
||||
resourceType, err := parseDiscoveryResourceType(parts[0])
|
||||
if err != nil {
|
||||
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
discoveryID := servicediscovery.MakeResourceID(resourceType, parts[1], parts[2])
|
||||
|
||||
var req servicediscovery.UpdateAvailabilityProposalRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(req.Status))
|
||||
if status != "dismissed" && status != "reviewable" {
|
||||
writeDiscoveryError(w, http.StatusBadRequest, "status must be dismissed or reviewable")
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateAvailabilityProposalDisposition(
|
||||
discoveryID,
|
||||
strings.TrimSpace(req.EvidenceFingerprint),
|
||||
status == "dismissed",
|
||||
); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, servicediscovery.ErrAvailabilityProposalNotFound):
|
||||
writeDiscoveryError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, servicediscovery.ErrAvailabilityProposalEvidenceChanged):
|
||||
writeDiscoveryError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to update availability proposal")
|
||||
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to update availability proposal")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
discovery, err := h.service.GetDiscovery(discoveryID)
|
||||
if err != nil || discovery == nil {
|
||||
writeDiscoveryError(w, http.StatusInternalServerError, "Proposal updated but failed to fetch result")
|
||||
return
|
||||
}
|
||||
if !h.isAdminRequest(r) {
|
||||
discovery = redactSensitiveFields(discovery)
|
||||
}
|
||||
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
|
||||
}
|
||||
|
||||
// HandleDeleteDiscovery handles DELETE /api/discovery/{type}/{target}/{id}
|
||||
func (h *DiscoveryHandlers) HandleDeleteDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
if h.service == nil {
|
||||
|
||||
@@ -364,6 +364,41 @@ func TestHandleDeleteDiscovery(t *testing.T) {
|
||||
assert.Nil(t, d)
|
||||
}
|
||||
|
||||
func TestHandleUpdateAvailabilityProposal(t *testing.T) {
|
||||
h, _, store := setupDiscoveryHandlers(t)
|
||||
proposal := servicediscovery.SuggestAvailabilityProbe(&servicediscovery.ResourceDiscovery{
|
||||
ServiceType: "grafana",
|
||||
ServiceName: "Grafana",
|
||||
}, "10.0.0.8")
|
||||
discovery := &servicediscovery.ResourceDiscovery{
|
||||
ID: "vm:node1:100",
|
||||
ResourceType: servicediscovery.ResourceTypeVM,
|
||||
ResourceID: "100",
|
||||
TargetID: "node1",
|
||||
SuggestedAvailabilityProbe: proposal,
|
||||
}
|
||||
require.NoError(t, store.Save(discovery))
|
||||
|
||||
body := fmt.Sprintf(`{"evidence_fingerprint":%q,"status":"dismissed"}`, proposal.EvidenceFingerprint)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/discovery/vm/node1/100/availability-proposal", bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUpdateAvailabilityProposal(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var updated servicediscovery.ResourceDiscovery
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&updated))
|
||||
assert.Equal(t, proposal.EvidenceFingerprint, updated.DismissedAvailabilityProbeFingerprint)
|
||||
|
||||
staleReq := httptest.NewRequest(http.MethodPut, "/api/discovery/vm/node1/100/availability-proposal", bytes.NewBufferString(`{"evidence_fingerprint":"sha256:stale","status":"dismissed"}`))
|
||||
staleRec := httptest.NewRecorder()
|
||||
h.HandleUpdateAvailabilityProposal(staleRec, staleReq)
|
||||
assert.Equal(t, http.StatusConflict, staleRec.Code)
|
||||
|
||||
invalidReq := httptest.NewRequest(http.MethodPut, "/api/discovery/vm/node1/100/availability-proposal", bytes.NewBufferString(fmt.Sprintf(`{"evidence_fingerprint":%q,"status":"accepted"}`, proposal.EvidenceFingerprint)))
|
||||
invalidRec := httptest.NewRecorder()
|
||||
h.HandleUpdateAvailabilityProposal(invalidRec, invalidReq)
|
||||
assert.Equal(t, http.StatusBadRequest, invalidRec.Code)
|
||||
}
|
||||
|
||||
func TestHandleGetStatus(t *testing.T) {
|
||||
h, _, _ := setupDiscoveryHandlers(t)
|
||||
|
||||
|
||||
@@ -372,6 +372,7 @@ func (r *Router) registerMonitoringResourceRoutes(
|
||||
// GET /api/discovery/agent/{agentId}/{resourceId}/progress → get scan progress
|
||||
// POST /api/discovery/agent/{agentId}/{resourceId} → trigger discovery
|
||||
// PUT /api/discovery/agent/{agentId}/{resourceId}/notes → update notes
|
||||
// PUT /api/discovery/agent/{agentId}/{resourceId}/availability-proposal → dismiss or restore a proposal
|
||||
// DELETE /api/discovery/agent/{agentId}/{resourceId} → delete discovery
|
||||
path := strings.TrimPrefix(req.URL.Path, pathPrefix)
|
||||
pathParts := strings.Split(strings.TrimSuffix(path, "/"), "/")
|
||||
@@ -406,6 +407,8 @@ func (r *Router) registerMonitoringResourceRoutes(
|
||||
}
|
||||
if strings.HasSuffix(req.URL.Path, "/notes") {
|
||||
r.discoveryHandlers.HandleUpdateNotes(w, req)
|
||||
} else if strings.HasSuffix(req.URL.Path, "/availability-proposal") {
|
||||
r.discoveryHandlers.HandleUpdateAvailabilityProposal(w, req)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
@@ -443,6 +446,8 @@ func (r *Router) registerMonitoringResourceRoutes(
|
||||
}
|
||||
if strings.HasSuffix(path, "/notes") {
|
||||
r.discoveryHandlers.HandleUpdateNotes(w, req)
|
||||
} else if strings.HasSuffix(path, "/availability-proposal") {
|
||||
r.discoveryHandlers.HandleUpdateAvailabilityProposal(w, req)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -2849,9 +2849,11 @@ func TestDiscoveryMutationEndpointsRequireWriteScopes(t *testing.T) {
|
||||
{method: http.MethodPost, path: "/api/discovery/run", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
||||
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
||||
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/availability-proposal", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
||||
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/availability-proposal", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite},
|
||||
{method: http.MethodPost, path: "/api/discovery/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
||||
{method: http.MethodPut, path: "/api/discovery/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
package servicediscovery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAvailabilityProposalNotFound = errors.New("availability proposal not found")
|
||||
ErrAvailabilityProposalEvidenceChanged = errors.New("availability proposal evidence changed")
|
||||
)
|
||||
|
||||
// tcpServiceDefaults maps service types to their default TCP probe port.
|
||||
// These are services without a web interface that are still commonly
|
||||
// monitored for availability (databases, message brokers, caches).
|
||||
@@ -38,25 +48,25 @@ func SuggestAvailabilityProbe(discovery *ResourceDiscovery, hostIP string) *Avai
|
||||
|
||||
// 1. Web services → HTTP/HTTPS probe
|
||||
if defaults, matched, ok := lookupWebServiceDefault(normalized); ok {
|
||||
return &AvailabilityProbeSuggestion{
|
||||
return finalizeAvailabilityProbeSuggestion(&AvailabilityProbeSuggestion{
|
||||
Protocol: defaults.Protocol,
|
||||
Address: hostIP,
|
||||
Port: defaults.Port,
|
||||
Path: defaults.Path,
|
||||
ServiceName: pickServiceName(discovery, matched),
|
||||
Reason: fmt.Sprintf("service default: %s", matched),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 2. TCP-only services (databases, brokers) → TCP probe
|
||||
if port, matched, ok := lookupTCPServiceDefault(normalized); ok {
|
||||
return &AvailabilityProbeSuggestion{
|
||||
return finalizeAvailabilityProbeSuggestion(&AvailabilityProbeSuggestion{
|
||||
Protocol: "tcp",
|
||||
Address: hostIP,
|
||||
Port: port,
|
||||
ServiceName: pickServiceName(discovery, matched),
|
||||
Reason: fmt.Sprintf("tcp service default: %s", matched),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Fallback: try hostname/name against the defaults maps.
|
||||
@@ -64,29 +74,76 @@ func SuggestAvailabilityProbe(discovery *ResourceDiscovery, hostIP string) *Avai
|
||||
hostnameNormalized := normalizeServiceTypeForLookup(discovery.Hostname)
|
||||
if hostnameNormalized != "" && hostnameNormalized != normalized {
|
||||
if defaults, matched, ok := lookupWebServiceDefault(hostnameNormalized); ok {
|
||||
return &AvailabilityProbeSuggestion{
|
||||
return finalizeAvailabilityProbeSuggestion(&AvailabilityProbeSuggestion{
|
||||
Protocol: defaults.Protocol,
|
||||
Address: hostIP,
|
||||
Port: defaults.Port,
|
||||
Path: defaults.Path,
|
||||
ServiceName: pickServiceName(discovery, matched),
|
||||
Reason: fmt.Sprintf("hostname match: %s", matched),
|
||||
}
|
||||
})
|
||||
}
|
||||
if port, matched, ok := lookupTCPServiceDefault(hostnameNormalized); ok {
|
||||
return &AvailabilityProbeSuggestion{
|
||||
return finalizeAvailabilityProbeSuggestion(&AvailabilityProbeSuggestion{
|
||||
Protocol: "tcp",
|
||||
Address: hostIP,
|
||||
Port: port,
|
||||
ServiceName: pickServiceName(discovery, matched),
|
||||
Reason: fmt.Sprintf("hostname tcp match: %s", matched),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeAvailabilityProbeSuggestion normalizes a suggestion and binds it to
|
||||
// the exact evidence an operator is being asked to review. The fingerprint is
|
||||
// intentionally independent of discovery timestamps and unrelated facts: a
|
||||
// dismissal reopens only when the proposed endpoint, behavior, identity, or
|
||||
// inference reason materially changes.
|
||||
func finalizeAvailabilityProbeSuggestion(suggestion *AvailabilityProbeSuggestion) *AvailabilityProbeSuggestion {
|
||||
if suggestion == nil {
|
||||
return nil
|
||||
}
|
||||
suggestion.Protocol = strings.ToLower(strings.TrimSpace(suggestion.Protocol))
|
||||
suggestion.Address = normalizeAvailabilitySuggestionAddress(suggestion.Address)
|
||||
suggestion.Path = normalizeAvailabilitySuggestionPath(suggestion.Path)
|
||||
suggestion.ServiceName = strings.TrimSpace(suggestion.ServiceName)
|
||||
suggestion.Reason = strings.TrimSpace(suggestion.Reason)
|
||||
fingerprintInput := strings.Join([]string{
|
||||
suggestion.Protocol,
|
||||
suggestion.Address,
|
||||
strconv.Itoa(suggestion.Port),
|
||||
suggestion.Path,
|
||||
strings.ToLower(suggestion.ServiceName),
|
||||
strings.ToLower(suggestion.Reason),
|
||||
}, "\x00")
|
||||
sum := sha256.Sum256([]byte(fingerprintInput))
|
||||
suggestion.EvidenceFingerprint = fmt.Sprintf("sha256:%x", sum[:])
|
||||
return suggestion
|
||||
}
|
||||
|
||||
func normalizeAvailabilitySuggestionAddress(address string) string {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if parsed := net.ParseIP(strings.Trim(trimmed, "[]")); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
return strings.ToLower(strings.TrimSuffix(trimmed, "."))
|
||||
}
|
||||
|
||||
func normalizeAvailabilitySuggestionPath(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
normalized := path.Clean("/" + strings.TrimPrefix(trimmed, "/"))
|
||||
if normalized == "/" && trimmed != "/" {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// normalizeServiceTypeForLookup normalizes a service type string for map lookup.
|
||||
func normalizeServiceTypeForLookup(serviceType string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(serviceType))
|
||||
|
||||
@@ -138,3 +138,22 @@ func TestSuggestAvailabilityProbe_ServiceNameFallback(t *testing.T) {
|
||||
t.Errorf("service_name = %q, want grafana (fallback to matched key)", got.ServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityProposalFingerprintTracksMaterialEvidence(t *testing.T) {
|
||||
discovery := &ResourceDiscovery{ServiceType: "grafana", ServiceName: "Grafana"}
|
||||
first := SuggestAvailabilityProbe(discovery, "GRAFANA.LOCAL.")
|
||||
second := SuggestAvailabilityProbe(discovery, "grafana.local")
|
||||
if first == nil || second == nil || first.EvidenceFingerprint == "" {
|
||||
t.Fatalf("expected fingerprinted suggestions: first=%+v second=%+v", first, second)
|
||||
}
|
||||
if first.EvidenceFingerprint != second.EvidenceFingerprint {
|
||||
t.Fatalf("equivalent endpoint fingerprints differ: %q != %q", first.EvidenceFingerprint, second.EvidenceFingerprint)
|
||||
}
|
||||
|
||||
changed := *second
|
||||
changed.Path = "/health"
|
||||
finalizeAvailabilityProbeSuggestion(&changed)
|
||||
if changed.EvidenceFingerprint == first.EvidenceFingerprint {
|
||||
t.Fatal("materially changed path must produce a new evidence fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,6 +1517,7 @@ func (s *Service) discoverDockerContainers(ctx context.Context, hosts []DockerHo
|
||||
// Suggest web interface URL and availability probe using Docker host hostname
|
||||
discovery.SuggestedURL = SuggestWebURL(discovery, host.Hostname)
|
||||
discovery.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(discovery, host.Hostname)
|
||||
preserveAvailabilityProposalDismissal(existing, discovery)
|
||||
|
||||
if err := s.store.Save(discovery); err != nil {
|
||||
log.Warn().Err(err).Str("id", id).Msg("failed to save discovery")
|
||||
@@ -1526,6 +1527,16 @@ func (s *Service) discoverDockerContainers(ctx context.Context, hosts []DockerHo
|
||||
}
|
||||
}
|
||||
|
||||
func preserveAvailabilityProposalDismissal(existing, next *ResourceDiscovery) {
|
||||
if existing == nil || next == nil || next.SuggestedAvailabilityProbe == nil {
|
||||
return
|
||||
}
|
||||
dismissed := strings.TrimSpace(existing.DismissedAvailabilityProbeFingerprint)
|
||||
if dismissed != "" && dismissed == next.SuggestedAvailabilityProbe.EvidenceFingerprint {
|
||||
next.DismissedAvailabilityProbeFingerprint = dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// enhanceWithDeepScan runs a deep scan and merges the results into the discovery.
|
||||
func (s *Service) enhanceWithDeepScan(ctx context.Context, discovery *ResourceDiscovery, host DockerHost) *ResourceDiscovery {
|
||||
s.mu.RLock()
|
||||
@@ -2064,6 +2075,7 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
if existing != nil {
|
||||
discovery.UserNotes = existing.UserNotes
|
||||
discovery.UserSecrets = existing.UserSecrets
|
||||
discovery.DismissedAvailabilityProbeFingerprint = existing.DismissedAvailabilityProbeFingerprint
|
||||
if discovery.DiscoveredAt.IsZero() || existing.DiscoveredAt.Before(discovery.DiscoveredAt) {
|
||||
discovery.DiscoveredAt = existing.DiscoveredAt
|
||||
}
|
||||
@@ -3552,6 +3564,12 @@ func (s *Service) UpdateNotes(id string, notes string, secrets map[string]string
|
||||
return s.store.UpdateNotes(id, notes, secrets)
|
||||
}
|
||||
|
||||
// UpdateAvailabilityProposalDisposition records a reviewed discovery proposal
|
||||
// without creating or activating a network check.
|
||||
func (s *Service) UpdateAvailabilityProposalDisposition(id, evidenceFingerprint string, dismissed bool) error {
|
||||
return s.store.UpdateAvailabilityProposalDisposition(id, evidenceFingerprint, dismissed)
|
||||
}
|
||||
|
||||
// DeleteDiscovery deletes a discovery.
|
||||
func (s *Service) DeleteDiscovery(id string) error {
|
||||
return s.store.Delete(id)
|
||||
|
||||
@@ -73,6 +73,10 @@ func cloneResourceDiscovery(src *ResourceDiscovery) *ResourceDiscovery {
|
||||
cloned.RawCommandOutput[k] = v
|
||||
}
|
||||
}
|
||||
if src.SuggestedAvailabilityProbe != nil {
|
||||
proposal := *src.SuggestedAvailabilityProbe
|
||||
cloned.SuggestedAvailabilityProbe = &proposal
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
@@ -125,6 +129,14 @@ func normalizeDiscovery(d *ResourceDiscovery) {
|
||||
if d.ResourceType == ResourceTypeAgent && strings.TrimSpace(d.AgentID) == "" {
|
||||
d.AgentID = d.TargetID
|
||||
}
|
||||
if d.SuggestedAvailabilityProbe != nil {
|
||||
d.SuggestedAvailabilityProbe = finalizeAvailabilityProbeSuggestion(d.SuggestedAvailabilityProbe)
|
||||
if d.DismissedAvailabilityProbeFingerprint != d.SuggestedAvailabilityProbe.EvidenceFingerprint {
|
||||
d.DismissedAvailabilityProbeFingerprint = ""
|
||||
}
|
||||
} else {
|
||||
d.DismissedAvailabilityProbeFingerprint = ""
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizeFingerprint(fp *ContainerFingerprint) {
|
||||
@@ -631,6 +643,32 @@ func (s *Store) UpdateNotes(id string, notes string, secrets map[string]string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAvailabilityProposalDisposition records an operator's decision against
|
||||
// the current evidence only. It rejects stale fingerprints rather than letting
|
||||
// an old browser hide a newly changed proposal.
|
||||
func (s *Store) UpdateAvailabilityProposalDisposition(id, evidenceFingerprint string, dismissed bool) error {
|
||||
discovery, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discovery == nil {
|
||||
return ErrAvailabilityProposalNotFound
|
||||
}
|
||||
if discovery.SuggestedAvailabilityProbe == nil {
|
||||
return ErrAvailabilityProposalNotFound
|
||||
}
|
||||
expected := discovery.SuggestedAvailabilityProbe.EvidenceFingerprint
|
||||
if strings.TrimSpace(evidenceFingerprint) == "" || evidenceFingerprint != expected {
|
||||
return ErrAvailabilityProposalEvidenceChanged
|
||||
}
|
||||
if dismissed {
|
||||
discovery.DismissedAvailabilityProbeFingerprint = expected
|
||||
} else {
|
||||
discovery.DismissedAvailabilityProbeFingerprint = ""
|
||||
}
|
||||
return s.Save(discovery)
|
||||
}
|
||||
|
||||
// GetMultiple retrieves multiple discoveries by ID.
|
||||
func (s *Store) GetMultiple(ids []string) ([]*ResourceDiscovery, error) {
|
||||
var discoveries []*ResourceDiscovery
|
||||
|
||||
@@ -3,6 +3,7 @@ package servicediscovery
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -171,6 +172,49 @@ func TestStore_SaveGetListAndNotes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAvailabilityProposalDispositionIsEvidenceBound(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore error: %v", err)
|
||||
}
|
||||
store.crypto = nil
|
||||
|
||||
discovery := &ResourceDiscovery{
|
||||
ID: MakeResourceID(ResourceTypeDocker, "host1", "grafana"),
|
||||
ResourceType: ResourceTypeDocker,
|
||||
ResourceID: "grafana",
|
||||
TargetID: "host1",
|
||||
SuggestedAvailabilityProbe: SuggestAvailabilityProbe(
|
||||
&ResourceDiscovery{ServiceType: "grafana", ServiceName: "Grafana"},
|
||||
"10.0.0.8",
|
||||
),
|
||||
}
|
||||
if err := store.Save(discovery); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
fingerprint := discovery.SuggestedAvailabilityProbe.EvidenceFingerprint
|
||||
if err := store.UpdateAvailabilityProposalDisposition(discovery.ID, fingerprint, true); err != nil {
|
||||
t.Fatalf("dismiss error: %v", err)
|
||||
}
|
||||
dismissed, _ := store.Get(discovery.ID)
|
||||
if dismissed.DismissedAvailabilityProbeFingerprint != fingerprint {
|
||||
t.Fatalf("dismissed fingerprint = %q, want %q", dismissed.DismissedAvailabilityProbeFingerprint, fingerprint)
|
||||
}
|
||||
|
||||
if err := store.UpdateAvailabilityProposalDisposition(discovery.ID, "sha256:stale", true); !errors.Is(err, ErrAvailabilityProposalEvidenceChanged) {
|
||||
t.Fatalf("stale dismissal error = %v, want ErrAvailabilityProposalEvidenceChanged", err)
|
||||
}
|
||||
|
||||
dismissed.SuggestedAvailabilityProbe.Path = "/health"
|
||||
if err := store.Save(dismissed); err != nil {
|
||||
t.Fatalf("Save changed proposal error: %v", err)
|
||||
}
|
||||
reopened, _ := store.Get(discovery.ID)
|
||||
if reopened.DismissedAvailabilityProbeFingerprint != "" {
|
||||
t.Fatalf("materially changed evidence retained dismissal: %q", reopened.DismissedAvailabilityProbeFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveCanonicalizesAgentAndTargetIDs(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
@@ -174,6 +174,10 @@ type ResourceDiscovery struct {
|
||||
// and known default ports. When approved by the user this becomes a canonical
|
||||
// availability target via POST /api/availability-targets.
|
||||
SuggestedAvailabilityProbe *AvailabilityProbeSuggestion `json:"suggested_availability_probe,omitempty"`
|
||||
// The exact proposal evidence an operator dismissed. A dismissal remains in
|
||||
// force only while it matches SuggestedAvailabilityProbe.EvidenceFingerprint;
|
||||
// materially changed discovery evidence is reviewable again.
|
||||
DismissedAvailabilityProbeFingerprint string `json:"dismissed_availability_probe_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// AvailabilityProbeSuggestion represents a suggested availability probe
|
||||
@@ -182,12 +186,13 @@ type ResourceDiscovery struct {
|
||||
// existing POST /api/availability-targets API. There is no second management
|
||||
// surface.
|
||||
type AvailabilityProbeSuggestion struct {
|
||||
Protocol string `json:"protocol"` // "http", "https", "tcp"
|
||||
Address string `json:"address"` // IP or hostname
|
||||
Port int `json:"port,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ServiceName string `json:"service_name"` // Human-readable service name for display
|
||||
Reason string `json:"reason"` // Why this suggestion was generated
|
||||
Protocol string `json:"protocol"` // "http", "https", "tcp"
|
||||
Address string `json:"address"` // IP or hostname
|
||||
Port int `json:"port,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ServiceName string `json:"service_name"` // Human-readable service name for display
|
||||
Reason string `json:"reason"` // Why this suggestion was generated
|
||||
EvidenceFingerprint string `json:"evidence_fingerprint"` // Stable identity for review/dismissal and stale-action rejection
|
||||
}
|
||||
|
||||
// DiscoveryFact represents a single discovered fact about a resource.
|
||||
@@ -297,23 +302,33 @@ type UpdateNotesRequest struct {
|
||||
UserSecrets map[string]string `json:"user_secrets,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAvailabilityProposalRequest records an explicit operator disposition
|
||||
// for the currently presented discovery proposal. The evidence fingerprint is
|
||||
// mandatory so a stale browser cannot dismiss materially newer evidence.
|
||||
type UpdateAvailabilityProposalRequest struct {
|
||||
EvidenceFingerprint string `json:"evidence_fingerprint"`
|
||||
Status string `json:"status"` // "dismissed" or "reviewable"
|
||||
}
|
||||
|
||||
// DiscoverySummary provides a summary of discoveries for listing.
|
||||
type DiscoverySummary struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType ResourceType `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
ServiceType string `json:"service_type"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ServiceVersion string `json:"service_version"`
|
||||
Category ServiceCategory `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
HasUserNotes bool `json:"has_user_notes"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"` // Current fingerprint
|
||||
NeedsDiscovery bool `json:"needs_discovery"` // True if fingerprint changed
|
||||
ID string `json:"id"`
|
||||
ResourceType ResourceType `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
ServiceType string `json:"service_type"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ServiceVersion string `json:"service_version"`
|
||||
Category ServiceCategory `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
HasUserNotes bool `json:"has_user_notes"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"` // Current fingerprint
|
||||
NeedsDiscovery bool `json:"needs_discovery"` // True if fingerprint changed
|
||||
SuggestedAvailabilityProbe *AvailabilityProbeSuggestion `json:"suggested_availability_probe,omitempty"`
|
||||
DismissedAvailabilityProbeFingerprint string `json:"dismissed_availability_probe_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// ToSummary converts a full discovery to a summary.
|
||||
@@ -321,21 +336,23 @@ func (d *ResourceDiscovery) ToSummary() DiscoverySummary {
|
||||
targetID := d.TargetID
|
||||
|
||||
return DiscoverySummary{
|
||||
ID: d.ID,
|
||||
ResourceType: d.ResourceType,
|
||||
ResourceID: d.ResourceID,
|
||||
TargetID: targetID,
|
||||
AgentID: d.AgentID,
|
||||
Hostname: d.Hostname,
|
||||
ServiceType: d.ServiceType,
|
||||
ServiceName: d.ServiceName,
|
||||
ServiceVersion: d.ServiceVersion,
|
||||
Category: d.Category,
|
||||
Confidence: d.Confidence,
|
||||
HasUserNotes: d.UserNotes != "",
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
Fingerprint: d.Fingerprint,
|
||||
NeedsDiscovery: false, // Will be set by caller if fingerprint changed
|
||||
ID: d.ID,
|
||||
ResourceType: d.ResourceType,
|
||||
ResourceID: d.ResourceID,
|
||||
TargetID: targetID,
|
||||
AgentID: d.AgentID,
|
||||
Hostname: d.Hostname,
|
||||
ServiceType: d.ServiceType,
|
||||
ServiceName: d.ServiceName,
|
||||
ServiceVersion: d.ServiceVersion,
|
||||
Category: d.Category,
|
||||
Confidence: d.Confidence,
|
||||
HasUserNotes: d.UserNotes != "",
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
Fingerprint: d.Fingerprint,
|
||||
NeedsDiscovery: false, // Will be set by caller if fingerprint changed
|
||||
SuggestedAvailabilityProbe: d.SuggestedAvailabilityProbe,
|
||||
DismissedAvailabilityProbeFingerprint: d.DismissedAvailabilityProbeFingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user