Add discovery-led service assurance onboarding

This commit is contained in:
Pulse Test
2026-08-30 18:28:24 +01:00
parent 65bec55229
commit 6ee3b7e24e
31 changed files with 1492 additions and 76 deletions
@@ -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
+37 -24
View File
@@ -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(
+30
View File
@@ -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 200399'
: '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 200399')).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>
+7
View File
@@ -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';
+12 -4
View File
@@ -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)
}
+68 -1
View File
@@ -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 {
+35
View File
@@ -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)
+5
View File
@@ -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)
}
+2
View File
@@ -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")
}
}
+18
View File
@@ -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)
+38
View File
@@ -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
+44
View File
@@ -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 {
+53 -36
View File
@@ -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,
}
}