diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index eeea1a4d23..36fbdbe6a7 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4785,6 +4785,29 @@ "run", "type-check" ] + }, + { + "id": "ra42-startup-agent-history", + "run": [ + "go", + "test", + "./internal/api", + "./internal/api/chartapi", + "-run", + "TestContract_MetricsHistoryLivePointOnlyWhileSourceReports|TestChartsDoNotSynthesizeCurrentMetricsForPendingEnrollment", + "-count=1" + ] + }, + { + "id": "ra42-startup-agent-report", + "run": [ + "go", + "test", + "./internal/monitoring", + "-run", + "TestHostAgentStartupWaitsWithinAcceptedReportLease", + "-count=1" + ] } ], "evidence": [ @@ -4805,6 +4828,24 @@ "kind": "file", "evidence_tier": "test-proof" }, + { + "repo": "pulse", + "path": "internal/api/chartapi/service_handlers_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/contract_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/monitoring/monitor_host_agents_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "tests/integration/tests/91-operational-trust-attention-workbench.spec.ts", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e8f16fca85..eb01f3116a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -9890,7 +9890,11 @@ node that PVE polling keeps fresh. The agent connection on the Connections list and the update-readiness agent-continuity check therefore age a silent agent from its last report instead of from the PVE poll, and mark it stale at their own heartbeat cutoff (`fleethealth.AgentStaleThreshold`, which is -separate from the monitoring reporting lease). Agent registration, enrolment, +separate from the monitoring reporting lease). A saved enrollment awaiting its +first report in the current monitor generation retains pending delivery and +its historical receipt under the monitoring-owned lease. Connections must not +label that enrollment active, or turn an explicitly expired lease active using +the longer heartbeat fallback. Agent registration, enrolment, install, update, removal and report identity are unchanged. ### Mock-mode read views fold linked fixture agents diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 43a1e07406..70922de20c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -21,6 +21,13 @@ ## Purpose +### Pending agent delivery supplies no live chart fallback + +A saved enrollment awaiting its first accepted report remains queryable by its +existing metrics target. History and summary chart routes retain stored series +but cannot synthesize a now-stamped live sample from that enrollment's absent +metrics. An accepted report restores normal live fallback behaviour. + ### Typed MCP fleet filters survive HTTP projection GET/DELETE capability projection preserves public string and boolean query @@ -8025,6 +8032,11 @@ API-backed lifecycle state. The retired `InfrastructureOperationsController.tsx` shell and `useInfrastructureReportingState.tsx` reporting path must not be reintroduced as parallel transport owners. +The `/api/connections` agent projection preserves monitoring's pending +first-report state across a restart without renewing the saved last-seen +timestamp. An explicitly offline agent cannot become active through the +longer fleet heartbeat fallback. API provider errors and pending provider +polls retain their own connection states. That same `/api/connections` projection also owns collection-method truth for the infrastructure ledger: `useConnectionsLedger.ts` must derive one canonical subtitle (`via platform API`, `via Pulse Agent`, or `via platform API and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e087037215..a126ddd80c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,18 @@ ## Purpose +### Agent first-report status + +Machines, resource detail drawers and agent connection rows use the shared +"Waiting for first report" label for monitoring's bounded pending startup +state. Machines treats that informational state as unknown rather than an outage. +The collapsed machine row shows the wording beside its name and suppresses +cached metric bars until accepted telemetry arrives. Consumers must not infer +the wait from a browser timer or hide a provider failure behind it. The expanded +drawer presents the wait as information and does not synthesize a current +chart reading from absent or cached metrics. Stored history remains available. +Pending API polls retain their own wording. + ### Backup posture failure and retry stay source-local (24 October 2026) The Backups posture notice offers a keyboard-operable retry with the existing diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 2405e31b85..3b9abdd4b6 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2680,6 +2680,17 @@ the live last-known host snapshot. After a server restart, durable continuity must still expire persisted operation alerts on the same accepted-telemetry lease; missing telemetry then remains visible through the separate confirmed connectivity lifecycle. +After a restart, a saved agent enrollment without a report in this monitor +generation waits only while its last accepted report remains inside that same +lease. The read projection publishes pending agent delivery and unknown +resource status, preserving identity and the historical receipt with no +metrics. An accepted report replaces that projection. Lease expiry publishes +offline even if no report arrives. Already-expired or removed enrollments do +not receive a new grace period, and provider failures remain authoritative. +Rejected duplicate or buffered reports cannot hydrate a live host or finish +the wait. They preserve transport liveness and the accepted-report watermark. +Realtime resource conversion carries the same source delivery map and original +receipt as the canonical API, including pending delivery and absent metrics. PBS backup snapshot refresh is a bounded monitoring hot path: group-level snapshot fetches must run through the fixed worker pool in `internal/monitoring/monitor_backups.go`, reuse cached snapshots on per-group @@ -4855,7 +4866,10 @@ path through `evaluateHostAgents` and the registry-backed store refresh. The metrics-history API's live fallback relies on the same projection: it treats a host row reading `offline` as past its lease, and an agent linked to -a Proxmox node then answers from the node row. For node rows it takes a +a Proxmox node then answers from the node row. A pending startup enrollment +also supplies no live fallback. History and summary charts keep any actual +stored observations without synthesizing zero-valued current samples. +For node rows it takes a projected `offline` or `unknown` as the lapse and otherwise reads the registry's sightings (`NodeView.SourceStatus`), because a projected `warning` is ambiguous: the stale pass shows an unreachable node that way, and so does a diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 45c155a84b..9a4f339938 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3152,6 +3152,12 @@ the agent resumes, without a reload. A stale Proxmox source can still lose the aggregate to a live agent while its own filesystem rows remain; such a guest stays on the Proxmox rule. The agent's OS, addresses and physical disk health carry no stale cue yet. +Realtime resource rows carry canonical source delivery status so an open +browser observes a server restart without a REST enrichment race. An explicit +pending agent delivery with unknown resource status withdraws that row's +previous current metrics and agent telemetry, while retaining identity and +the existing history target. The same full-snapshot and delta merge enforce +this boundary; unrelated rows retain their usual incremental update path. Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency boundary for collapse: the owning table (Workloads for guests, the Proxmox nodes table for nodes, the Docker hosts table for Docker hosts) owns which diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4f2024b15d..37eda02dc8 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -18,6 +18,13 @@ ## Purpose +### Agent delivery is separate from authenticated transport + +A saved agent enrollment or an authenticated rejected report does not establish +accepted current telemetry after a server restart. Pending delivery stays +informational, with its historical receipt, and cannot establish a live metric +reading or imply that credentials or command-channel policy were verified. + ### Restricted outbound connections keep approved destinations pinned `pkg/securityutil/outbound_http.go` owns restricted HTTP destination validation, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e57c4e303f..c288127e96 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,13 @@ ## Purpose +### Agent startup is not storage evidence + +An agent's pending first report after a server restart is a monitoring-owned +delivery state. Its retained enrollment and historical receipt carry no fresh +storage metrics, healthy storage verdict or recovery evidence. Storage and +recovery consumers continue to require their own source observations. + ### Failed posture refreshes withdraw retained assessments (24 October 2026) Protection posture remains visible while a refresh is pending, but a settled diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5852f4af2c..145a210e8f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -676,8 +676,8 @@ fleet CPU attribution. ### Host continuity overlays reuse one build per registry generation -`ReadStateWithHostContinuity` overlays standalone agents that are offline -across a restart onto the live read state by building a registry clone. The +`ReadStateWithHostContinuity` overlays standalone agents awaiting a report or +offline across a restart onto the live read state by building a registry clone. The monitor requests that overlay on every canonical read-state lookup, which alert evaluation makes once per resource per poll, so a single offline agent made every poll rebuild the registry once per resource for up to 72 hours. The @@ -911,6 +911,14 @@ and linked Proxmox state. Even a future-dated saved enrollment cannot overwrite live truth. Skipped records cannot attach superseded IDs or migrate operator state. Distinct machines sharing a hostname remain distinct, and absent hosts retain their historical identity without manufacturing a live sighting. +During the monitoring-owned reporting lease, an absent saved agent has unknown +resource status and pending agent delivery. Registry ingestion, stale marking, +cloning and serialized read-state seeding preserve that pending delivery rather +than treating its historical timestamp as a current sighting. Continuity carries +no metrics. Its lease and transition to offline are owned by monitoring. +Realtime rows carry source delivery alongside resource status. A pending +enrollment explicitly withdraws an earlier browser observation rather than +retaining cached metrics or an older agent facet as current evidence. `TestHostContinuityCannotOverwriteCurrentResource`, `TestHostContinuityRetainsDistinctMachinesWithSameHostname`, and the monitoring `TestHostContinuityPreservesLiveAgentAfterReenrollment` pin this boundary (#1913). diff --git a/frontend-modern/browser-verification/7fa94a1e4ee7d130.json b/frontend-modern/browser-verification/7fa94a1e4ee7d130.json new file mode 100644 index 0000000000..da597ea849 --- /dev/null +++ b/frontend-modern/browser-verification/7fa94a1e4ee7d130.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "base_sha": "0a98f0a8debb2c96870d5ef0133d3b31491e830b", + "verified_at": "2026-10-24T15:41:10.218Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Settings/connectionsTableModel.ts", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx", + "frontend-modern/src/features/standalone/standalonePageModel.ts", + "frontend-modern/src/utils/agentResources.ts", + "frontend-modern/src/utils/resourceStateAdapters.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "406ff7d3ec98ea605ccc1accc2cfe8e26c594cad8557c75b9383f2bac6f48038", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "05197e1b31e6f25812fe2c652d0676ebbc3b607c4dc67df11bd2303967793c1d", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "9c800cdffe393b3c7671f05940c6f2b682e67dff9b0922f2a098ef726e4f6aba", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "f636b3c362e516f6d9078aa1c4dc431db500fec333a2645f9d04c6502b140498", + "frontend-modern/src/components/Settings/connectionsTableModel.ts": "c62059c55e1f4ff5ab397a2306ac2ec72ae1f0a84c9cc65a084f49cfef3f2ef3", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts": "30c286d18cf51dff8984ce49ce6c76d5db1ecdcee642c80dd2e3ac38055d2728", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "0c11c74b2e92f84af5c83a5064d04e3ee13843a43273bcef0038c8070648fcbe", + "frontend-modern/src/features/standalone/standalonePageModel.ts": "f7de0eadcd75e9b6e575a6fb53a5430985578fbdca504757d7a2457d1ab8a9da", + "frontend-modern/src/utils/agentResources.ts": "c3c6a003da42e0075cad3b9a5530cc64bf718529c9ea648e5777b8779933b5c6", + "frontend-modern/src/utils/resourceStateAdapters.ts": "a13924bf6410c4f3439ce59e68b582c45b88553f455fc143d4bd35f73ac7624a" + }, + "routes": [ + "/standalone", + "/settings/infrastructure" + ], + "viewports": [ + { + "width": 1440, + "height": 1000 + }, + { + "width": 768, + "height": 1024 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Recent saved agents show Waiting for first report with unknown status and no current metric bars.", + "An overdue saved agent remains offline. An unreported enrollment becomes offline at its original lease deadline.", + "Removed saved enrollments remain absent after startup and restart.", + "Expanded pending drawer shows an informational wait and empty history without fabricated zero-valued current readings.", + "An accepted report ends the wait and shows live CPU and memory readings.", + "An open browser and inline drawer survive an actual server restart, withdraw current readings, and retain labelled stored history.", + "Settings agent rows show the first-report label on desktop and narrow layouts." + ], + "interactions": [ + "Focus the machine expansion button, open and close with Enter, press Escape, and collapse through the drawer header.", + "Open the machine drawer by selecting its name on desktop and narrow viewports; inspect wrapping, chart placement and dismissal.", + "Navigate between /standalone and /settings/infrastructure and scroll the narrow Settings page to inspect the last pending row in the viewport.", + "Send an authenticated synthetic agent report and observe the transition to healthy telemetry.", + "Keep the page and drawer open during a private server restart and inspect the restored pending state and stored history.", + "Wait through the original reporting deadline, reload, and verify the overdue and removed enrollment states." + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index ceb7593639..b499345e33 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -13,6 +13,8 @@ import { getAlertAttentionCopy } from '@/features/alerts/metricAlertPresentation import { useAlertsActivation } from '@/stores/alertsActivation'; import { getAlertsForUnifiedResource, getOwnAlertsForUnifiedResource } from '@/utils/alerts'; import { GuestDrawerHistory } from '@/components/Workloads/GuestDrawerHistory'; +import { InlineNotice } from '@/components/shared/InlineNotice'; +import { getAgentFirstReportStatusIndicator } from '@/utils/agentResources'; import { getAlertStatedRowLabels, getDrawerChartAlerts, @@ -404,6 +406,13 @@ export const ResourceDetailDrawerOverviewTab: Component + + {(waiting) => ( + + {waiting().label} + + )} + diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index bf2584fc39..9ad3607691 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -17,6 +17,7 @@ import { import { TagBadges } from '@/components/shared/TagBadges'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; +import { getAgentFirstReportStatusIndicator } from '@/utils/agentResources'; import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference'; import { getDockerContainerUpdateAction } from '@/features/docker/dockerContainerUpdateAction'; import { getDockerImageOperationalPresentation } from '@/features/docker/dockerImagePresentation'; @@ -419,8 +420,10 @@ const dockerVolumeSection = (resource: Resource, now: number): DetailSection | n // A resource that is not reporting must say so in front, not only in Details: // offline is danger-toned (with when it was last seen), and a state Pulse // cannot vouch for is warning-toned, so the drawer lifts either. -const observedStateRow = (status: string | undefined, lastSeen: string | undefined) => { - const state = (status || 'unknown').trim().toLowerCase(); +const observedStateRow = (resource: Resource, lastSeen: string | undefined) => { + const waiting = getAgentFirstReportStatusIndicator(resource); + if (waiting) return makeDetailRow('Observed state', waiting.label); + const state = (resource.status || 'unknown').trim().toLowerCase(); const label = state.charAt(0).toUpperCase() + state.slice(1); const offline = state === 'offline'; return makeDetailRow( @@ -544,7 +547,7 @@ export const useResourceSummarySections = ( 'Observed state', getDockerContainerRunStatePresentation(props.resource), ) - : observedStateRow(props.resource.status, props.drawer.lastSeen()), + : observedStateRow(props.resource, props.drawer.lastSeen()), docker ? dockerRuntimeRow( 'Health check', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index e22c399c5b..cfe7ff7e79 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -80,6 +80,22 @@ afterEach(() => { describe('ResourceDetailDrawer machine inventory', () => { const gib = 1024 ** 3; + it('keeps startup waiting informational and withholds the current history fallback', () => { + const [machine, setMachine] = createStore( + resource({ + status: 'unknown', + sourceStatus: { agent: { status: 'pending' } }, + cpu: { current: 12 }, + }), + ); + render(() => ); + expect(screen.getByRole('status')).toHaveTextContent('Waiting for first report'); + expect(screen.queryByTestId('resource-drawer-exceptions')).toBeNull(); + expect(screen.getByTestId('machine-history')).not.toHaveAttribute('data-cpu'); + setMachine({ status: 'online', sourceStatus: { agent: { status: 'online' } } }); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.getByTestId('machine-history')).toHaveAttribute('data-cpu', '12'); + }); it('states one facts line and keeps the inventory under a closed Details', () => { render(() => ( { }); describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => { + it('does not supply a current fallback while a saved agent awaits its first report', () => { + const waiting = baseResource({ + status: 'unknown', + sourceStatus: { agent: { status: 'pending' } }, + cpu: { current: 12 }, + memory: { current: 25 }, + disk: { current: 30 }, + }); + expect(getResourceMetricsHistoryCurrentMetrics(waiting)).toEqual({}); + expect(getResourceMetricsHistoryTarget(waiting)).not.toBeNull(); + }); it('returns every metric as a finite number when the resource is fully populated', () => { // All finiteMetric calls take the "finite number -> return value" arm; // memory/disk ternaries take the TRUE arm and getMemoryPercent/getDiskPercent diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts index a7f9db1ac9..540b0f3b1e 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts @@ -21,6 +21,7 @@ import { import { asTrimmedString } from '@/utils/stringUtils'; import { getMemoryObservationPresentation } from '@/utils/memoryObservation'; import { getWorkloadGuestDiskRead } from '@/utils/workloadGuestPresentation'; +import { getAgentFirstReportStatusIndicator } from '@/utils/agentResources'; const finiteMetric = (value: number | undefined): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; @@ -260,6 +261,7 @@ export const getResourceMetricsHistoryDeferredMetrics = ( export const getResourceMetricsHistoryCurrentMetrics = ( resource: Resource, ): Record => { + if (getAgentFirstReportStatusIndicator(resource)) return {}; const disk = guestDiskReading(resource); const diskPercent = resource.disk && (!disk || disk.state === 'current') diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index f55864e026..49490700d7 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -6,6 +6,7 @@ import type { ResourceVMwareMeta } from '@/types/resource'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { getAgentStatusIndicator } from '@/utils/status'; +import { getAgentFirstReportStatusIndicator } from '@/utils/agentResources'; import { mapDockerContainerStatus } from '@/features/docker/dockerPageModel'; import { dedupeResourceBadges, @@ -111,7 +112,8 @@ export const useResourceDetailDrawerDerivedState = ( const statusIndicator = createMemo(() => resource().type === 'app-container' ? mapDockerContainerStatus(resource()) - : getAgentStatusIndicator({ status: resource().status }), + : (getAgentFirstReportStatusIndicator(resource()) ?? + getAgentStatusIndicator({ status: resource().status })), ); const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource())); // An open drawer keeps its rows while a resource's timestamps stay the same, diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx index 84ab570363..abe2f56b04 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx @@ -1,4 +1,12 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, + within, +} from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import infrastructureSourceManagerSource from '../InfrastructureSourceManager.tsx?raw'; import { @@ -14,6 +22,9 @@ import { type InfrastructureSystemRow, } from '../connectionsTableModel'; import type { Connection } from '@/api/connections'; +import { ConnectionsAPI } from '@/api/connections'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { useConnectionsLedger } from '../useConnectionsLedger'; const clusterDeployApi = vi.hoisted(() => ({ getCandidates: vi.fn(), @@ -112,9 +123,39 @@ const member = ( describe('InfrastructureSourceManager setup summary', () => { afterEach(() => { cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); }); + it('renders the canonical first-report label through the real connection ledger', async () => { + resetCreateNonSuspendingQueryCacheForTest(); + vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ + connections: [connectionFixture({ state: 'pending', name: 'Waiting host' })], + systems: [], + }); + const ledger = renderHook(() => useConnectionsLedger()); + await waitFor(() => expect(ledger.result.rows()).toHaveLength(1)); + const onOpenConnection = vi.fn(); + render(() => ( + []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly={false} + onAddSource={vi.fn()} + onAddInfrastructure={vi.fn()} + onOpenConnection={onOpenConnection} + onOpenAgentDoctor={vi.fn()} + /> + )); + expect(screen.getByText('Waiting for first report')).toBeInTheDocument(); + expect(screen.queryByText('Unreachable')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /^Manage$/i })); + expect(onOpenConnection).toHaveBeenCalled(); + }); + it('renders manual discovery as a visible operator action', () => { const onRunDiscovery = vi.fn(); const onOpenDiscoverySettings = vi.fn(); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 7a52713c0c..384951e125 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -3,6 +3,7 @@ import { createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Connection } from '@/api/connections'; import { + agentAttachmentProblem, infrastructureRowMonitoringPath, type InfrastructureSystemMemberRow, type InfrastructureSystemRow, @@ -329,6 +330,42 @@ describe('InfrastructureWorkspace', () => { const renderWorkspace = (propOverrides: Record = {}) => render(() => () as any); + it('keeps a working provider active while its attached agent awaits a first report', async () => { + const provider = connectionFixture(); + const agent = connectionFixture({ id: 'agent:waiting', type: 'agent', state: 'pending' }); + connectionState.rows = [ + { + id: provider.id, + ownerType: provider.type, + name: provider.name, + subtitle: 'via platform API and Pulse Agent', + source: 'both', + host: provider.address, + coverageLabels: ['VMs'], + statusLabel: 'Active', + statusClassName: 'bg-green-100', + agentUpdateCount: 0, + lastActivityText: '1m ago', + ...emptyFleetRow, + problem: agentAttachmentProblem(agent), + enabled: true, + canEdit: true, + canPause: false, + canRemove: true, + isAgent: false, + isCluster: false, + attachedConnections: [agent], + members: [], + connection: provider, + }, + ]; + renderWorkspace(); + await waitFor(() => expect(screen.getByText('Waiting for first report')).toBeInTheDocument()); + expect(screen.getAllByText('Active').length).toBeGreaterThan(0); + expect(screen.queryByText('Unreachable')).toBeNull(); + expect(screen.getByRole('button', { name: /^Manage$/i })).toBeInTheDocument(); + }); + it('renders the source manager landing without empty platform sections', async () => { renderWorkspace(); diff --git a/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts b/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts index 655c5c08d7..680a72e553 100644 --- a/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Connection, ConnectionAgentIdentity } from '@/api/connections'; import { + agentAttachmentProblem, connectionAgentIdentitySummary, fleetGovernanceSignalsForConnection, primaryRowProblem, @@ -26,6 +27,16 @@ const connectionFixture = (overrides: Partial = {}): Connection => ( }); describe('visibleFleetGovernanceSignals', () => { + it('names a pending attached agent without calling it an outage', () => { + expect(agentAttachmentProblem(connectionFixture({ state: 'pending' }))).toMatchObject({ + label: 'Waiting for first report', + tone: 'warning', + }); + expect(agentAttachmentProblem(connectionFixture({ state: 'unreachable' }))).toMatchObject({ + label: 'Agent unreachable', + tone: 'critical', + }); + }); it('surfaces the failed module and its operator-facing reason', () => { const signals = fleetGovernanceSignalsForConnection( connectionFixture({ diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index 74821b59e1..c66e42d3ed 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -21,6 +21,31 @@ describe('useConnectionsLedger', () => { expect(useConnectionsLedgerSource).not.toContain('createResource'); }); + it('names the first-report wait only for pending agents', async () => { + const connections = [ + { id: 'agent:waiting', type: 'agent', name: 'Waiting host', source: 'agent' }, + { id: 'pve:waiting', type: 'pve', name: 'Waiting API', source: 'manual' }, + ].map((value) => ({ + ...value, + address: value.name, + state: 'pending', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: null, + lastError: null, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + })) as Connection[]; + vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ connections, systems: [] }); + const { result } = renderHook(() => useConnectionsLedger()); + await waitFor(() => expect(result.rows()).toHaveLength(2)); + expect(result.rows().find((row) => row.ownerType === 'agent')?.statusLabel).toBe( + 'Waiting for first report', + ); + expect(result.rows().find((row) => row.ownerType === 'pve')?.statusLabel).toBe('Pending'); + }); + it('renders standalone agent rows with compact host identity and endpoint context', async () => { const connections: Connection[] = [ { diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index e6e169d14d..6b9aad8e6e 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -13,6 +13,7 @@ import type { ConnectionFleetVersionDrift, } from '@/api/connections'; import { getAgentHostProfileFamily } from '@/utils/platformSupportManifest'; +import { AGENT_WAITING_FOR_FIRST_REPORT_LABEL } from '@/utils/agentResources'; import { buildDockerPath, buildKubernetesPath, @@ -868,7 +869,7 @@ export const agentAttachmentProblem = (agent: Connection): InfrastructureRowProb }; case 'pending': return { - label: 'Agent pending first report', + label: AGENT_WAITING_FOR_FIRST_REPORT_LABEL, detail: agent.stateReason || 'The Pulse Agent has not reported yet.', tone: 'warning', }; diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index 5a54535676..a8b382aa61 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -1,6 +1,7 @@ import { createMemo } from 'solid-js'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation'; +import { AGENT_WAITING_FOR_FIRST_REPORT_LABEL } from '@/utils/agentResources'; import { ConnectionsAPI, type Connection, @@ -342,6 +343,10 @@ const buildRow = ( const clusterName = system?.clusterName?.trim(); const isCluster = ownerType === 'pve' && Boolean(clusterName) && members.length > 0; const isStandaloneAgent = !isCluster && primaryConnection.type === 'agent'; + const statusLabel = + rolledState === 'pending' && primaryConnection.type === 'agent' + ? AGENT_WAITING_FOR_FIRST_REPORT_LABEL + : presentation.label; const name = ownerType === 'pve' && clusterName ? clusterName @@ -414,7 +419,7 @@ const buildRow = ( source, host, coverageLabels, - statusLabel: presentation.label, + statusLabel, statusClassName: presentation.badgeClass, agentUpdateCount: agentUpdateCountFor(componentConnections), lastActivityText: rollup diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index bb6c45b22b..db26cbb8f6 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -63,7 +63,10 @@ import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import type { Disk } from '@/types/api'; import type { Resource, ResourceAvailabilityMeta } from '@/types/resource'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; -import { getActionableAgentIdFromResource } from '@/utils/agentResources'; +import { + getActionableAgentIdFromResource, + getAgentFirstReportStatusIndicator, +} from '@/utils/agentResources'; import { getUnifiedResourceAlertStyles } from '@/utils/alerts'; import { formatBytes, formatSpeed, formatObservedSpeed, normalizeDiskArray } from '@/utils/format'; import { STORAGE_KEYS } from '@/utils/localStorage'; @@ -1579,7 +1582,9 @@ export const AgentsMachinesTable: Component<{ return `Agent has stopped reporting.${when} Re-run the install command from the Pulse UI to refresh its token.`; }; const indicator = () => getStandaloneResourceStatusIndicator(machine, now()); - const canRenderMetrics = () => indicator().variant !== 'danger'; + const awaitingReport = () => getAgentFirstReportStatusIndicator(machine); + const canRenderMetrics = () => + !awaitingReport() && indicator().variant !== 'danger'; const telemetryFallback = () => canRenderMetrics() ? outdatedAgentTelemetryFallbackFor(machine, props.targetAgentVersion) @@ -1721,12 +1726,21 @@ export const AgentsMachinesTable: Component<{ title={indicator().label} ariaLabel={indicator().label} /> - +
+ + + {(waiting) => ( + + {waiting().label} + + )} + +
diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx index a746ff2c85..bbdd702c14 100644 --- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx @@ -162,6 +162,32 @@ afterEach(() => { }); describe('AgentsMachinesTable', () => { + it('shows the first-report wait beside the name and hides cached metric bars until reporting', () => { + const [machine, setMachine] = createStore( + resource({ + id: 'waiting', + name: 'Waiting host', + status: 'unknown', + lastSeen: Date.now(), + sourceStatus: { agent: { status: 'pending' } }, + cpu: { current: 12 }, + }), + ); + render(() => ( + + )); + expect(screen.getByText('Waiting for first report')).toBeVisible(); + expect(screen.queryByTestId('agent-machine-cpu-bar')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Offline, 0' })).toBeInTheDocument(); + setMachine({ status: 'online', sourceStatus: { agent: { status: 'online' } } }); + expect(screen.queryByText('Waiting for first report')).not.toBeInTheDocument(); + expect(screen.getByTestId('agent-machine-cpu-bar')).toHaveAttribute('data-usage', '12'); + }); it('keeps health counts and selected rows truthful for retained, arriving and withdrawn reports', () => { const start = Date.parse('2026-10-11T17:00:00Z'); vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'], now: start }); diff --git a/frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts b/frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts index 0fa53e96df..22dc62cd97 100644 --- a/frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts +++ b/frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts @@ -3,6 +3,7 @@ import type { Resource } from '@/types/resource'; import { buildStandalonePageModel, buildStandalonePostureSummary, + getStandaloneResourceStatusIndicator, sortStandaloneResourcesByAttention, } from '../standalonePageModel'; @@ -21,6 +22,28 @@ const resource = (overrides: Partial): Resource => }) as Resource; describe('standalonePageModel', () => { + it('keeps startup waiting out of the outage and attention counts, then follows recovery or expiry', () => { + const saved = resource({ status: 'unknown', sourceStatus: { agent: { status: 'pending' } } }); + expect(getStandaloneResourceStatusIndicator(saved)).toEqual({ + variant: 'info', + label: 'Waiting for first report', + }); + expect(buildStandalonePostureSummary([saved])).toMatchObject({ + total: 1, + unknown: 1, + critical: 0, + attention: 0, + }); + const now = Date.now(); + const online = resource({ + status: 'online', + lastSeen: now, + sourceStatus: { agent: { status: 'online' } }, + }); + expect(getStandaloneResourceStatusIndicator(online, now).label).toBe('Online'); + const expired = resource({ status: 'offline', sourceStatus: { agent: { status: 'stale' } } }); + expect(getStandaloneResourceStatusIndicator(expired).variant).toBe('danger'); + }); it('classifies each resource once per attention sort and reclassifies on the next call', () => { const now = 1_700_000_030_000; let firstStatus: Resource['status'] = 'online'; diff --git a/frontend-modern/src/features/standalone/standalonePageModel.ts b/frontend-modern/src/features/standalone/standalonePageModel.ts index b784a9a679..27e6d74224 100644 --- a/frontend-modern/src/features/standalone/standalonePageModel.ts +++ b/frontend-modern/src/features/standalone/standalonePageModel.ts @@ -1,6 +1,9 @@ import type { Resource } from '@/types/resource'; import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation'; -import { isPulseAgentPlatformResource } from '@/utils/agentResources'; +import { + getAgentFirstReportStatusIndicator, + isPulseAgentPlatformResource, +} from '@/utils/agentResources'; import { getSimpleStatusIndicator } from '@/utils/status'; export interface StandalonePageModel { @@ -28,6 +31,8 @@ export const isAgentlessAvailabilityResource = (resource: Resource): boolean => const AGENT_REPORT_STALE_AFTER_MS = 5 * 60 * 1000; export const getStandaloneResourceStatusIndicator = (resource: Resource, nowMs = Date.now()) => { + const waiting = getAgentFirstReportStatusIndicator(resource); + if (waiting) return waiting; const indicator = getSimpleStatusIndicator(resource.status); if (indicator.variant === 'danger') return indicator; const availability = getAvailabilityProbePresentation(resource, new Date(nowMs)); diff --git a/frontend-modern/src/utils/__tests__/agentResources.test.ts b/frontend-modern/src/utils/__tests__/agentResources.test.ts index 85ba245c0d..435d2723a9 100644 --- a/frontend-modern/src/utils/__tests__/agentResources.test.ts +++ b/frontend-modern/src/utils/__tests__/agentResources.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { + getAgentFirstReportStatusIndicator, getActionableAgentIdFromResource, getActionableDockerRuntimeIdFromResource, getActionableKubernetesClusterIdFromResource, @@ -31,6 +32,23 @@ const makeResource = (overrides: Partial = {}): Resource => }) as Resource; describe('agentResources', () => { + it('labels an unobserved startup agent without overriding a provider verdict', () => { + const pending = { agent: { status: 'pending', lastSeen: '2026-10-24T12:00:00Z' } }; + expect( + getAgentFirstReportStatusIndicator( + makeResource({ status: 'unknown', sourceStatus: pending }), + ), + ).toEqual({ + variant: 'info', + label: 'Waiting for first report', + }); + for (const status of ['online', 'offline', 'warning'] as const) { + expect( + getAgentFirstReportStatusIndicator(makeResource({ status, sourceStatus: pending })), + ).toBeUndefined(); + } + expect(getAgentFirstReportStatusIndicator(makeResource({ status: 'unknown' }))).toBeUndefined(); + }); it('resolves explicit agent ids from canonical and platform facets', () => { expect( getExplicitAgentIdFromResource( diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index dee95b77f2..cd49b84cb1 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -12,6 +12,53 @@ import { } from '../resourceStateAdapters'; import type { Resource } from '@/types/resource'; +it('withdraws a previous browser observation when realtime reports startup pending delivery', () => { + const existing: Resource = { + id: 'waiting-agent', + type: 'agent', + name: 'Host', + displayName: 'Host', + platformId: 'agent', + platformType: 'agent', + sourceType: 'agent', + status: 'online', + lastSeen: 1000, + sources: ['agent'], + cpu: { current: 12 }, + memory: { current: 25 }, + uptime: 60, + agent: { agentId: 'waiting-agent', cpuCount: 4 } as Resource['agent'], + }; + const incoming: Resource = { + id: existing.id, + type: 'agent', + name: 'Host', + displayName: 'Host', + platformId: 'agent', + platformType: 'agent', + sourceType: 'agent', + status: 'unknown', + lastSeen: 1000, + sources: ['agent'], + sourceStatus: { agent: { status: 'pending' } }, + agent: { agentId: 'waiting-agent' } as Resource['agent'], + }; + for (const result of [ + mergeCanonicalResourceSnapshot([incoming], [existing]), + mergeCanonicalResourceDeltaSnapshot([incoming], [existing], new Set([incoming.id])), + ]) { + expect(result[0]).toMatchObject({ + status: 'unknown', + lastSeen: 1000, + sourceStatus: incoming.sourceStatus, + }); + expect(result[0]?.cpu).toBeUndefined(); + expect(result[0]?.memory).toBeUndefined(); + expect(result[0]?.uptime).toBeUndefined(); + expect(result[0]?.agent?.cpuCount).toBeUndefined(); + } +}); + const createNodeResource = (platformData: Record): Resource => ({ id: 'node-1', diff --git a/frontend-modern/src/utils/agentResources.ts b/frontend-modern/src/utils/agentResources.ts index d0996dd5f6..baf79c8b65 100644 --- a/frontend-modern/src/utils/agentResources.ts +++ b/frontend-modern/src/utils/agentResources.ts @@ -6,6 +6,16 @@ import { } from '@/utils/discoveryTarget'; import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms'; import { asTrimmedString } from '@/utils/stringUtils'; +import type { StatusIndicator } from '@/utils/status'; + +export const AGENT_WAITING_FOR_FIRST_REPORT_LABEL = 'Waiting for first report'; + +export const getAgentFirstReportStatusIndicator = ( + resource: Pick, +): StatusIndicator | undefined => + resource.sourceStatus?.agent?.status === 'pending' && resource.status === 'unknown' + ? { variant: 'info', label: AGENT_WAITING_FOR_FIRST_REPORT_LABEL } + : undefined; const AGENT_FACET_INFRASTRUCTURE_TYPES = new Set(['agent', 'pbs', 'pmg']); const AGENT_PROFILE_ASSIGNABLE_TYPES = new Set([ diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 6c30e16786..3863e1b3a4 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -27,6 +27,7 @@ import { $RAW } from 'solid-js/store'; import type { Resource } from '@/types/resource'; import { getExplicitAgentIdFromResource, + getAgentFirstReportStatusIndicator, getExplicitResourceClusterName, hasDockerFacetEvidence, } from '@/utils/agentResources'; @@ -161,8 +162,8 @@ const mergeAgentFacet = (incoming?: T, existing?: T): T | return next; }; -// Source status is per-source evidence the realtime payload never carries, so -// it survives merges as REST enrichment. Entries of sources the merged row no +// Thin producers can omit source status, so it survives as REST enrichment. +// Entries of sources the merged row no // longer lists are stale: they would keep a split-off provider claiming the row. const pruneSourceStatus = ( value: unknown, @@ -941,6 +942,19 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): } else if (merged.sourceStatus !== undefined) { delete merged.sourceStatus; } + if (getAgentFirstReportStatusIndicator(incoming)) { + // A new monitor generation has no accepted telemetry for this enrollment. + // Its explicit delivery state withdraws the previous browser observation. + merged.cpu = undefined; + merged.memory = undefined; + merged.disk = undefined; + merged.network = undefined; + merged.diskIO = undefined; + merged.temperature = undefined; + merged.uptime = undefined; + merged.agent = incoming.agent; + merged.platformData = incoming.platformData; + } return merged; }; diff --git a/internal/api/chartapi/service.go b/internal/api/chartapi/service.go index 9793337aa4..26a14f2e61 100644 --- a/internal/api/chartapi/service.go +++ b/internal/api/chartapi/service.go @@ -362,7 +362,7 @@ func (r *Service) HandleCharts(w http.ResponseWriter, req *http.Request) { if batchMetrics, ok := agentBatchMetrics[request.SQLResourceID]; ok { oldestTimestamp = fillChartSeriesFromBatch(agentData[hID], batchMetrics, oldestTimestamp) } - if len(agentData[hID]["cpu"]) == 0 { + if len(agentData[hID]["cpu"]) == 0 && !hostAgentAwaitingFirstReport(h) { agentData[hID]["cpu"] = []MetricPoint{{Timestamp: currentTime, Value: h.CPUPercent()}} agentData[hID]["memory"] = []MetricPoint{{Timestamp: currentTime, Value: h.MemoryPercent()}} agentData[hID]["disk"] = []MetricPoint{{Timestamp: currentTime, Value: h.DiskPercent()}} @@ -478,6 +478,11 @@ func parseWorkloadMaxPoints(raw string) int { func ParseWorkloadMaxPoints(raw string) int { return parseWorkloadMaxPoints(raw) } +func hostAgentAwaitingFirstReport(host *unifiedresources.HostView) bool { + status, ok := host.SourceStatus(unifiedresources.SourceAgent) + return ok && status.Status == "pending" +} + func hostAgentChartRequest(host *unifiedresources.HostView) (string, monitoring.GuestChartRequest, bool) { if host == nil { return "", monitoring.GuestChartRequest{}, false @@ -1959,6 +1964,9 @@ func (r *Service) HandleInfrastructureCharts(w http.ResponseWriter, req *http.Re if len(agentData[hID][metricType]) > 0 { continue } + if hostAgentAwaitingFirstReport(h) { + continue + } var value float64 hasFallbackValue := true switch metricType { diff --git a/internal/api/chartapi/service_handlers_test.go b/internal/api/chartapi/service_handlers_test.go index be011c96d5..2d7a71818b 100644 --- a/internal/api/chartapi/service_handlers_test.go +++ b/internal/api/chartapi/service_handlers_test.go @@ -7,10 +7,12 @@ import ( "net/http/httptest" "reflect" "sort" + "strings" "testing" "time" "unsafe" + "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/testutil" @@ -236,6 +238,51 @@ func TestHandleCharts_UsesCanonicalMockUnifiedReadStateForVMwareHosts(t *testing } } +func TestChartsDoNotSynthesizeCurrentMetricsForPendingEnrollment(t *testing.T) { + monitor, state, history := newTestMonitor(t) + syncTestResourceStore(t, monitor, state) + continuity := config.NewHostContinuityStore(t.TempDir(), nil) + if err := continuity.Upsert(config.HostContinuityEntry{HostID: "waiting-agent", Hostname: "waiting", LastSeen: time.Now().Add(-time.Second), IntervalSeconds: 30}); err != nil { + t.Fatal(err) + } + setUnexportedField(t, monitor, "hostContinuityStore", continuity) + for _, stored := range []bool{false, true} { + if stored { + history.AddGuestMetric("agent:waiting-agent", "cpu", 37, time.Now().Add(-time.Minute)) + } + for _, path := range []string{"/api/charts?range=5m", "/api/charts/infrastructure?range=5m"} { + service := NewService(fixedMonitorResolver{monitor: monitor}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if strings.HasPrefix(path, "/api/charts/infrastructure") { + service.HandleInfrastructureCharts(rec, req) + } else { + service.HandleCharts(rec, req) + } + var response struct { + AgentData map[string]VMChartData `json:"agentData"` + } + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d: %s", path, rec.Code, rec.Body.String()) + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + series := response.AgentData["waiting-agent"] + if _, ok := response.AgentData["waiting-agent"]; !ok { + t.Fatalf("%s lost the pending enrollment", path) + } + if stored { + if len(series["cpu"]) != 1 || series["cpu"][0].Value != 37 { + t.Fatalf("%s lost stored history: %+v", path, series) + } + } else if len(series) != 0 { + t.Fatalf("%s invented current readings: %+v", path, series) + } + } + } +} + func TestHandleInfrastructureCharts_Lightweight(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ diff --git a/internal/api/connections_aggregator.go b/internal/api/connections_aggregator.go index eb32d90d67..8dd63aa70e 100644 --- a/internal/api/connections_aggregator.go +++ b/internal/api/connections_aggregator.go @@ -613,7 +613,16 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim versionDrift := fleethealth.DeriveAgentVersionDrift(currentAgentVersion, expectedAgentVersion) updateAvailable := versionDrift == fleethealth.AgentVersionBehind agentIdentity := connectionAgentIdentityForHost(host) - switch fleethealth.DeriveAgentLiveness(host.LastSeen, now, host.IntervalSeconds) { + liveness := fleethealth.DeriveAgentLiveness(host.LastSeen, now, host.IntervalSeconds) + if host.Status == "pending" { + liveness = fleethealth.AgentLivenessPending + reason = "Waiting for first report after server restart" + } else if host.Status == "offline" && lastSeen != nil { + // An expired accepted-report lease is already an offline verdict. The + // fleet's longer fallback heartbeat cutoff cannot turn it active again. + liveness = fleethealth.AgentLivenessStale + } + switch liveness { case fleethealth.AgentLivenessPending: state = ConnectionStatePending case fleethealth.AgentLivenessStale: diff --git a/internal/api/connections_aggregator_test.go b/internal/api/connections_aggregator_test.go index a8d68727bf..4af4841114 100644 --- a/internal/api/connections_aggregator_test.go +++ b/internal/api/connections_aggregator_test.go @@ -180,6 +180,8 @@ func TestBuildConnections_AgentStateFromLastSeen(t *testing.T) { now := time.Now() in := aggregatorInputs{ hosts: []models.Host{ + {ID: "startup", Hostname: "waiting", Status: "pending", LastSeen: now.Add(-10 * time.Second), IntervalSeconds: 30}, + {ID: "expired-startup", Hostname: "missed-report", Status: "offline", LastSeen: now.Add(-4 * time.Minute), IntervalSeconds: 30}, {ID: "fresh", Hostname: "h1", LastSeen: now.Add(-10 * time.Second), IntervalSeconds: 30}, {ID: "stale", Hostname: "h2", LastSeen: now.Add(-5*time.Minute - time.Second), IntervalSeconds: 30}, {ID: "never", Hostname: "h3"}, @@ -200,6 +202,13 @@ func TestBuildConnections_AgentStateFromLastSeen(t *testing.T) { if byID["agent:never"].State != ConnectionStatePending { t.Fatalf("never-reported agent: got %q, want pending", byID["agent:never"].State) } + startup := byID["agent:startup"] + if startup.State != ConnectionStatePending || startup.StateReason != "Waiting for first report after server restart" || startup.LastSeen == nil || !startup.LastSeen.Equal(now.Add(-10*time.Second)) { + t.Fatalf("saved startup agent reported live: %+v", startup) + } + if byID["agent:expired-startup"].State != ConnectionStateStale { + t.Fatal("expired startup lease became active without a report") + } for _, c := range got { if c.Capabilities.SupportsPause || c.Capabilities.SupportsScope { t.Fatalf("agents must not advertise pause/scope capabilities: %+v", c) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d5c9f597a2..4678e26bf9 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -13939,6 +13939,11 @@ func TestContract_MetricsHistoryLivePointOnlyWhileSourceReports(t *testing.T) { setUnexportedField(t, monitor, "state", state) setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour)) setUnexportedField(t, monitor, "metricsStore", store) + continuity := config.NewHostContinuityStore(t.TempDir(), nil) + if err := continuity.Upsert(config.HostContinuityEntry{HostID: "agent-waiting", Hostname: "waiting", LastSeen: now.Add(-time.Second), IntervalSeconds: 30}); err != nil { + t.Fatal(err) + } + setUnexportedField(t, monitor, "hostContinuityStore", continuity) router := &Router{monitor: monitor} // Preserve the source's own verdict through the stale pass (#2634): a @@ -13976,6 +13981,8 @@ func TestContract_MetricsHistoryLivePointOnlyWhileSourceReports(t *testing.T) { } for _, tc := range []struct{ resourceType, resourceID, metric string }{ + {"agent", "agent-waiting", "cpu"}, + {"agent", "agent-waiting", "temperature"}, {"agent", "agent-silent", "cpu"}, {"agent", "agent-silent", "temperature"}, {"node", "pve-unreachable", "memory"}, @@ -14032,6 +14039,11 @@ func TestContract_MetricsHistoryLivePointOnlyWhileSourceReports(t *testing.T) { t.Fatalf("%s %s: current %s not served as a live point: source=%q points=%+v", tc.resourceType, tc.resourceID, tc.metric, resp.Source, resp.Points) } } + // A saved enrollment may still have real history from its previous run. + monitor.GetMetricsHistory().AddGuestMetric("agent:agent-waiting", "cpu", 37, now.Add(-time.Minute)) + if resp, _ := history("resourceType=agent&resourceId=agent-waiting&metric=cpu"); resp.Source == "live" || len(resp.Points) != 1 || resp.Points[0].Value != 37 { + t.Fatalf("pending enrollment lost stored history or relabelled it as live: %+v", resp) + } } // A row lapses only when it records a reading source and every one it records diff --git a/internal/api/router.go b/internal/api/router.go index 035defcb78..05c9663f75 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6537,9 +6537,11 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) _, lapsed := lapsedRows[kind+"\x00"+id] return lapsed } - // HostsSnapshot reports an agent row past its reporting lease as offline. + // Saved enrollment is not live telemetry while awaiting its first report, + // and HostsSnapshot reports an expired reporting lease as offline. hostReporting := func(host *models.Host) bool { - return !strings.EqualFold(strings.TrimSpace(host.Status), "offline") && !rowLapsed("host", host.ID) + status := strings.ToLower(strings.TrimSpace(host.Status)) + return status != "offline" && status != "pending" && !rowLapsed("host", host.ID) } // The poller marks a node it cannot reach, or one the cluster lists but // this poll did not observe, offline or unknown and keeps earlier diff --git a/internal/models/converters.go b/internal/models/converters.go index 8d5099df04..57aba52a47 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -1154,6 +1154,7 @@ type ResourceConvertInput struct { PlatformType string SourceType string Sources []string + SourceStatus json.RawMessage ParentID string ParentName string ChildCount int @@ -1241,6 +1242,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend { PlatformType: input.PlatformType, SourceType: input.SourceType, Sources: append([]string(nil), input.Sources...), + SourceStatus: input.SourceStatus, ParentID: input.ParentID, ParentName: input.ParentName, ChildCount: input.ChildCount, diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index f76f395fcf..3c796e380e 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -1038,10 +1038,11 @@ type ResourceFrontend struct { DisplayName string `json:"displayName"` // Platform/Source - PlatformID string `json:"platformId"` - PlatformType string `json:"platformType"` - SourceType string `json:"sourceType"` - Sources []string `json:"sources,omitempty"` + PlatformID string `json:"platformId"` + PlatformType string `json:"platformType"` + SourceType string `json:"sourceType"` + Sources []string `json:"sources,omitempty"` + SourceStatus json.RawMessage `json:"sourceStatus,omitempty"` // Hierarchy ParentID string `json:"parentId,omitempty"` diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0d93c6a082..b6576231f5 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3901,6 +3901,9 @@ func hostFromReadStateView(view *unifiedresources.HostView) models.Host { func hostAgentLivenessFromReadStateView(view *unifiedresources.HostView, now time.Time) (string, time.Time) { status, lastSeen := string(view.Status()), view.LastSeen() agentSighting, ok := view.SourceStatus(unifiedresources.SourceAgent) + if ok && agentSighting.Status == "pending" { + return "pending", agentSighting.LastSeen + } if !ok || agentSighting.LastSeen.IsZero() { return status, lastSeen } @@ -7225,6 +7228,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re PlatformType: monitorPlatformType(&resource, resourceType), SourceType: monitorSourceType(resource.Sources), Sources: monitorSourceKeys(resource.Sources), + SourceStatus: monitorRawJSON(resource.SourceStatus), ParentID: monitorStringValue(resource.ParentID), ParentName: resource.ParentName, ChildCount: resource.ChildCount, diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 182d27c0ec..6305f771b7 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1812,8 +1812,11 @@ func (m *Monitor) applyRejectedHostReportLiveness( if host.LastSeen.IsZero() || receivedAt.Sub(host.LastSeen) > hostAgentHealthWindow(host.IntervalSeconds) { host.Status = "offline" + } else { + host.Status = "pending" } - m.state.UpsertHost(host) + // A rejected report does not hydrate live telemetry after a restart. + // Leave the saved enrollment in its bounded first-report projection. } // An authenticated arrival proves the transport is reachable, but a report diff --git a/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go b/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go index 65daf1fca5..a68f8e18b8 100644 --- a/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go +++ b/internal/monitoring/monitor_host_agent_removal_lifecycle_test.go @@ -587,14 +587,14 @@ func TestHostAgentRemovalLifecycleKeepsOfflineRowRemovableAfterRestart(t *testin } // Reconstruct the monitor without another agent report. The durable row - // must remain visible as offline so its removal action stays reachable. + // must remain visible while awaiting a report so removal stays reachable. restarted := newHostRemovalLifecycleMonitor(t, dataPath) hosts := restarted.HostsSnapshot() if len(hosts) != 1 || hosts[0].ID != host.ID { t.Fatalf("restart host inventory = %+v, want continuity row %q", hosts, host.ID) } - if hosts[0].Status != "offline" { - t.Fatalf("restart continuity status = %q, want offline", hosts[0].Status) + if hosts[0].Status != "pending" { + t.Fatalf("restart continuity status = %q, want pending", hosts[0].Status) } for i := 0; i < 3; i++ { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 5332e2f6b9..31fd41d691 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -35,6 +35,92 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +func TestHostAgentStartupWaitsWithinAcceptedReportLease(t *testing.T) { + dir := t.TempDir() + original := issue1654Monitor() + original.hostContinuityStore = config.NewHostContinuityStore(dir, nil) + report := issue1654Report(time.Now().UTC()) + report.SequenceID = agentshost.FormatReportSequenceID("startup-report", 1) + host, err := original.ApplyHostReport(report, nil) + if err != nil { + t.Fatal(err) + } + + restarted := issue1654Monitor() + restarted.hostContinuityStore = config.NewHostContinuityStore(dir, nil) + if err := restarted.hostContinuityStore.Upsert(config.HostContinuityEntry{ + HostID: "removed-agent", Hostname: "removed-host", LastSeen: host.LastSeen, + IntervalSeconds: 30, RemovedAt: host.LastSeen, + }); err != nil { + t.Fatal(err) + } + base := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + restarted.SetResourceStore(base) + if rejected, err := restarted.ApplyHostReport(report, nil); err != nil || rejected.Status != "pending" { + t.Fatalf("duplicate startup report ended the first-report wait: %+v, %v", rejected, err) + } + lease := hostAgentHealthWindow(host.IntervalSeconds) + for _, tc := range []struct { + name string + age time.Duration + want string + }{ + {"restart", time.Second, "pending"}, + {"deadline", lease, "pending"}, + {"no-report-before-deadline", lease + time.Nanosecond, "offline"}, + } { + t.Run(tc.name, func(t *testing.T) { + now := host.LastSeen.Add(tc.age) + read := restarted.readStateWithStandaloneHostContinuityAt(base, now) + views := read.Hosts() + if len(views) != 1 { + t.Fatalf("saved hosts = %d, want 1", len(views)) + } + status, seen := hostAgentLivenessFromReadStateView(views[0], now) + if status != tc.want || !seen.Equal(host.LastSeen) || views[0].AgentID() != host.ID { + t.Fatalf("restored status/receipt/identity = %s %s %s", status, seen, views[0].AgentID()) + } + if tc.want == "pending" { + sighting, ok := views[0].SourceStatus(unifiedresources.SourceAgent) + if !ok || sighting.Status != "pending" || views[0].Status() != unifiedresources.StatusUnknown { + t.Fatalf("saved enrollment became a current observation: %+v", sighting) + } + } + if len(restarted.GetLiveHostsSnapshot()) != 0 || len(base.Hosts()) != 0 { + t.Fatal("read overlay mutated live state") + } + resources := read.(*unifiedresources.MonitorAdapter).GetAll() + if resources[0].Metrics != nil { + t.Fatal("saved enrollment manufactured telemetry before its first report") + } + frontend := models.ConvertResourceToFrontend(monitorResourceToConvertInput(resources[0])) + var delivery map[string]unifiedresources.SourceStatus + if err := json.Unmarshal(frontend.SourceStatus, &delivery); err != nil { + t.Fatal(err) + } + if tc.want == "pending" && (delivery["agent"].Status != "pending" || frontend.Status != "unknown") { + t.Fatalf("realtime projection lost startup delivery: %+v", delivery) + } + if frontend.CPU != nil || frontend.Memory != nil || frontend.Disk != nil || frontend.LastSeen != host.LastSeen.UnixMilli() { + t.Fatal("realtime projection changed the accepted receipt or supplied metrics") + } + }) + } + // A real accepted report ends the wait and supplies the live observation. + report.Timestamp = report.Timestamp.Add(time.Second) + report.SequenceID = agentshost.FormatReportSequenceID("startup-report", 2) + if _, err := restarted.ApplyHostReport(report, nil); err != nil { + t.Fatal(err) + } + views := restarted.readStateWithStandaloneHostContinuity(restarted.GetUnifiedReadStateOrSnapshot()).Hosts() + if len(views) != 1 || views[0].Status() != unifiedresources.StatusOnline { + t.Fatal("first accepted report did not bring the saved agent online") + } + if sighting, ok := views[0].SourceStatus(unifiedresources.SourceAgent); !ok || sighting.Status != "online" { + t.Fatalf("first accepted report retained pending delivery: %+v", sighting) + } +} + func TestGuestPollOwnershipRuntimeDoesNotWithdrawIndependentAgentEvidence(t *testing.T) { c := &guestPollOwnershipClient{cpu: .1} m := guestPollOwnershipMonitor(t, c) diff --git a/internal/monitoring/monitored_system_usage.go b/internal/monitoring/monitored_system_usage.go index 6b20663ad0..7093487aed 100644 --- a/internal/monitoring/monitored_system_usage.go +++ b/internal/monitoring/monitored_system_usage.go @@ -66,6 +66,13 @@ func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot { func (m *Monitor) readStateWithStandaloneHostContinuity( readState unifiedresources.ReadState, +) unifiedresources.ReadState { + return m.readStateWithStandaloneHostContinuityAt(readState, time.Now().UTC()) +} + +func (m *Monitor) readStateWithStandaloneHostContinuityAt( + readState unifiedresources.ReadState, + now time.Time, ) unifiedresources.ReadState { if m == nil || readState == nil { return readState @@ -114,7 +121,20 @@ func (m *Monitor) readStateWithStandaloneHostContinuity( // it, but never present persisted telemetry as a current online sighting. host := hostFromContinuityEntry(entry) host.Status = "offline" - records = append(records, unifiedresources.HostIngestRecord(host)) + waiting := hostAgentReportCurrent(entry.LastSeen, entry.IntervalSeconds, now) + if waiting { + host.Status = "pending" + } + record := unifiedresources.HostIngestRecord(host) + record.Resource.Metrics = nil + if waiting { + // The saved receipt bounds the wait, but is not a delivery in this + // monitor generation. Keep its timestamp without claiming freshness. + record.Resource.SourceStatus = map[unifiedresources.DataSource]unifiedresources.SourceStatus{ + unifiedresources.SourceAgent: {Status: "pending", LastSeen: entry.LastSeen}, + } + } + records = append(records, record) } if len(records) == 0 { return readState diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 00cc540bec..0f9a6419dd 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -1998,7 +1998,7 @@ func (rr *ResourceRegistry) markStaleLocked(now time.Time, thresholds map[DataSo previousStatus := resource.Status staleFound := false for source, status := range resource.SourceStatus { - if status.LastSeen.IsZero() { + if status.LastSeen.IsZero() || status.Status == "pending" { continue } if now.Sub(status.LastSeen) > sourceStaleThreshold(source, status, thresholds) { @@ -3102,7 +3102,11 @@ func (rr *ResourceRegistry) ingestRecord(source DataSource, sourceID string, res } resource.Sources = []DataSource{source} sighting := resource.SourceStatus[source] - sighting.Status = sourceSightingStatus(resource.LastSeen) + // Only continuity can describe an enrolled agent awaiting a report in the + // current monitor generation. Its old receipt is a deadline, not a sighting. + if !(onlyMissing && source == SourceAgent && sighting.Status == "pending") { + sighting.Status = sourceSightingStatus(resource.LastSeen) + } sighting.LastSeen = resource.LastSeen sighting.reported = resource.Status resource.SourceStatus = map[DataSource]SourceStatus{source: sighting} diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index c1b03d66fa..761c421916 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -14,6 +14,38 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory" ) +func TestPendingHostContinuityPreservesUnknownDeliveryAcrossSerializedReads(t *testing.T) { + seen := time.Now().Add(-time.Minute).UTC() + record := HostIngestRecord(models.Host{ID: "waiting-agent", Hostname: "waiting-host", Status: "pending", LastSeen: seen}) + record.Resource.SourceStatus = map[DataSource]SourceStatus{SourceAgent: {Status: "pending", LastSeen: seen}} + rr := NewRegistry(nil) + id := rr.ingestRecord(SourceAgent, record.SourceID, record.Resource, record.Identity, true) + rr.MarkStale(time.Now(), map[DataSource]time.Duration{SourceAgent: time.Second}) + encoded, err := json.Marshal(rr.List()) + if err != nil { + t.Fatal(err) + } + var resources []Resource + if err := json.Unmarshal(encoded, &resources); err != nil { + t.Fatal(err) + } + copy := NewRegistry(nil) + copy.IngestResourcesWithStaleThresholds(resources, map[DataSource]time.Duration{SourceAgent: time.Second}) + got, ok := copy.Get(id) + if !ok || got.Status != StatusUnknown || got.SourceStatus[SourceAgent].Status != "pending" || !got.LastSeen.Equal(seen) || got.Agent.Stale { + t.Fatalf("waiting enrollment acquired a fresh or offline verdict: %+v", got) + } + // Normal ingest must replace the continuity marker with a real delivery. + rr.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{{ID: "waiting-agent", Hostname: "waiting-host", Status: "online", LastSeen: time.Now().UTC()}}}) + got, ok = rr.Get(id) + if !ok || got.Status != StatusOnline || got.SourceStatus[SourceAgent].Status != "online" { + t.Fatalf("accepted live sighting did not replace pending continuity: %+v", got) + } + if replaced := rr.ingestRecord(SourceAgent, record.SourceID, record.Resource, record.Identity, true); replaced != "" { + t.Fatal("pending continuity replaced an existing live observation") + } +} + // The snapshot entry point shares the registry's journal ownership, without // mutating either the snapshot or the alert's recorded metadata. func TestProxmoxDiskAlertSnapshotOwnershipMatchesRegistry(t *testing.T) {