diff --git a/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md b/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md index 2fec1f442..214b8eeb3 100644 --- a/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md +++ b/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7d7038b91..944ce288c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index c519f2296..9101b24f2 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index cb1c13670..9a26e23e1 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 925f67936..5d2128ef3 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 896a3c871..1ac3cb6e9 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/api/__tests__/discovery.test.ts b/frontend-modern/src/api/__tests__/discovery.test.ts index b9d530f9d..16655d78b 100644 --- a/frontend-modern/src/api/__tests__/discovery.test.ts +++ b/frontend-modern/src/api/__tests__/discovery.test.ts @@ -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( diff --git a/frontend-modern/src/api/discovery.ts b/frontend-modern/src/api/discovery.ts index 025920a67..b137513c9 100644 --- a/frontend-modern/src/api/discovery.ts +++ b/frontend-modern/src/api/discovery.ts @@ -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 { + 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 */ diff --git a/frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx b/frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx new file mode 100644 index 000000000..a490887b1 --- /dev/null +++ b/frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx @@ -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 = (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(null); + const [testResult, setTestResult] = createSignal(null); + const [createdTarget, setCreatedTarget] = createSignal(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 ( + + {(current) => ( +
+
+
+
+

+ Suggested service verification +

+ + Discovery evidence + +
+

+ Pulse inferred a useful check from this service. Review exactly what will run; + nothing is created until you choose the active-check action below. +

+
+ +
+ + +

Suggestion dismissed

+

+ It stays hidden for this evidence fingerprint. A materially changed endpoint or + service identity will make it reviewable again. +

+ + + } + > +
+
+

+ Endpoint · inferred +

+ + {endpointLabel(current())} + +
+
+

+ Expected behavior · reviewed default +

+

+ {expectedBehaviorLabel(current())} +

+
+
+ +

+ Why Pulse proposed it: {current().reason}. The check will attach to this canonical + resource, not a guessed hostname match. +

+ +
+ + + +
+ + + {(match) => ( +
+ {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.`} +
+ )} +
+ + + {(result) => ( +
+ {testResultLabel(result())} +
+ )} +
+ + {(created) => ( +
+
+ )} +
+ + {(message) => ( +

{message()}

+ )} +
+ +
+ + + +
+
+ + setShowBulkReview(false)} + panelClass="max-w-3xl" + ariaLabel="Review machine assurance suggestions" + > +
+
+
+

+ Machine assurance suggestions +

+

+ 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. +

+
+ setShowBulkReview(false)} + > + +
+
+ +
+ Loading discovered services… +
+
+ 0} + fallback={ +

+ No availability suggestions are currently available for this machine. +

+ } + > + + {(item) => { + const itemProposal = () => item.suggested_availability_probe!; + const itemDismissed = () => isAvailabilityProposalDismissed(item); + return ( +
+
+
+
+
+ + {endpointLabel(itemProposal())} + +

+ {expectedBehaviorLabel(itemProposal())} · {itemProposal().reason} +

+
+ +
+
+ ); + }} +
+
+ + {(message) => ( +

{message()}

+ )} +
+
+
+
+
+ )} +
+ ); +}; diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 4e37e0e2d..1417ee04a 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -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 = (props) => { isScanning, liveElapsedSeconds, notesText, + mutateDiscovery, saveError, scanError, scanProgress, @@ -845,6 +849,18 @@ export const DiscoveryTab: Component = (props) => { + + + + {/* CLI Access */}
diff --git a/frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx b/frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx new file mode 100644 index 000000000..933be97ba --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx @@ -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 => ({ + 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(() => ( + + )); + + 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(() => ( + + )); + + 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(); + }); +}); diff --git a/frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts b/frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts new file mode 100644 index 000000000..0c30cc28b --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts @@ -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 => ({ + 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 => ({ + 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], + ); + }); +}); diff --git a/frontend-modern/src/components/Discovery/availabilityProposalModel.ts b/frontend-modern/src/components/Discovery/availabilityProposalModel.ts new file mode 100644 index 000000000..e56cf8ca9 --- /dev/null +++ b/frontend-modern/src/components/Discovery/availabilityProposalModel.ts @@ -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, + ); + }); +} diff --git a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts index 11895ae4b..28de4bad8 100644 --- a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts +++ b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts @@ -345,6 +345,8 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) { isScanning, liveElapsedSeconds, notesText, + mutateDiscovery: mutate, + refetchDiscovery: refetch, saveError, scanError, scanProgress, diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index e85259a96..7b80aa5f4 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -247,6 +247,7 @@ const DrawerContent: Component = (props) => { agentId={config().agentId} resourceId={config().resourceId} hostname={config().hostname} + canonicalResourceId={props.resource.id} commandsEnabled={drawer.agentMeta()?.commandsEnabled} showManualRunAction /> diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 9f84d2b00..d69e02c2d 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -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} /> diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index 996b115d6..291b30525 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -150,6 +150,7 @@ export const GuestDrawer: Component = (props) => { agentId={discoveryAgentId()} resourceId={discoveryResourceId()} hostname={props.guest.name} + canonicalResourceId={props.guest.id} showManualRunAction /> diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.tsx index 3513b6656..38006379a 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.tsx @@ -166,6 +166,7 @@ export const NodeDrawer: Component = (props) => { agentId={target().agentId} resourceId={target().agentId} hostname={target().hostname || displayName()} + canonicalResourceId={props.node.id} showManualRunAction /> diff --git a/frontend-modern/src/features/docker/DockerHostDrawer.tsx b/frontend-modern/src/features/docker/DockerHostDrawer.tsx index 096e25d0d..241b95394 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawer.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawer.tsx @@ -158,6 +158,7 @@ export const DockerHostDrawer: Component = (props) => { agentId={config().agentId} resourceId={config().resourceId} hostname={config().hostname} + canonicalResourceId={props.host.id} showManualRunAction /> diff --git a/frontend-modern/src/types/discovery.ts b/frontend-modern/src/types/discovery.ts index b9a61ef64..b1257c809 100644 --- a/frontend-modern/src/types/discovery.ts +++ b/frontend-modern/src/types/discovery.ts @@ -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'; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d65a5bcd2..8a2f9e574 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) } diff --git a/internal/api/discovery_handlers.go b/internal/api/discovery_handlers.go index 6ff505886..06da80430 100644 --- a/internal/api/discovery_handlers.go +++ b/internal/api/discovery_handlers.go @@ -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 { diff --git a/internal/api/discovery_handlers_test.go b/internal/api/discovery_handlers_test.go index 5e91adc4b..4f099eefb 100644 --- a/internal/api/discovery_handlers_test.go +++ b/internal/api/discovery_handlers_test.go @@ -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) diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 51d61826a..8599aac08 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -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) } diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index ede62b604..7620104ed 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -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}, diff --git a/internal/servicediscovery/availability_suggestion.go b/internal/servicediscovery/availability_suggestion.go index 2d9b5de49..54c1590e0 100644 --- a/internal/servicediscovery/availability_suggestion.go +++ b/internal/servicediscovery/availability_suggestion.go @@ -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)) diff --git a/internal/servicediscovery/availability_suggestion_test.go b/internal/servicediscovery/availability_suggestion_test.go index fe4d8bb77..09017a971 100644 --- a/internal/servicediscovery/availability_suggestion_test.go +++ b/internal/servicediscovery/availability_suggestion_test.go @@ -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") + } +} diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index a3f181795..6761bcc67 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -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) diff --git a/internal/servicediscovery/store.go b/internal/servicediscovery/store.go index 1bd402e1c..ebf1b9b0d 100644 --- a/internal/servicediscovery/store.go +++ b/internal/servicediscovery/store.go @@ -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 diff --git a/internal/servicediscovery/store_test.go b/internal/servicediscovery/store_test.go index a1efdccbd..67fc64bdc 100644 --- a/internal/servicediscovery/store_test.go +++ b/internal/servicediscovery/store_test.go @@ -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 { diff --git a/internal/servicediscovery/types.go b/internal/servicediscovery/types.go index 726303fb9..acea130b7 100644 --- a/internal/servicediscovery/types.go +++ b/internal/servicediscovery/types.go @@ -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, } }