diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 63bab6578..73a1e11a9 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -991,14 +991,21 @@ projection once per refresh and may render its current delivery outcome on an unacknowledged active-alert card. Missing or failed diagnosis reads degrade to no delivery line; they must not hide alert truth or trigger per-card requests. `internal/alerts/eventlog/` and `internal/alerts/event_emission.go` own the -additive append-only alert event record. Persistent managers enable a +canonical append-only alert event record. Persistent managers enable a SQLite-backed store under the alerts data directory; ephemeral managers record nothing unless a store is installed explicitly. Resolution, acknowledgement, unacknowledgement, escalation, flapping detection, dispatch, quiet-hours deferral, and suppression append immutable events without changing lifecycle -or delivery behavior. The write path is non-blocking and fail-open for alert -evaluation: a full buffer counts and drops its event, while an unavailable -store disables recording. Events retain for 90 days and prune hourly. Fired and +or delivery behavior. Snapshot-bearing lifecycle transitions commit +synchronously before downstream lifecycle projections run; they never share +the droppable diagnostic buffer because alert history is reconstructed from +them. High-volume notification decisions remain non-blocking and fail-open for +alert evaluation: a full diagnostic buffer counts and drops that delivery +evidence. Failed asynchronous batches do not advance the successful-write +counter, and `Flush` must report the failure or timeout rather than falsely +claiming the batch landed. An unavailable or failed lifecycle store degrades +history reads to the recovery model without hiding live active-alert truth. +Events retain for 90 days and prune hourly. Fired and refired lifecycle events come only from the reducer core's explicit activation events: canonical lifecycle reactivation maps `EventRefired` separately, while shared metric activation records one fired event when its pending incident @@ -1020,8 +1027,11 @@ Lifecycle events now carry a full alert snapshot and `internal/alerts/history_projection.go` can fold those snapshots into one history row per occurrence. Existing pre-snapshot SQLite stores upgrade in place, and rows without snapshots remain readable but cannot contribute to the -projection. With an event log enabled, it is the alert-history read authority; -managers without one retain the in-memory JSON-history model as their fallback. +projection. The event log becomes the alert-history read authority only after +legacy history is absent or its complete import and retirement succeeds. +Managers without an authoritative event store, and stores that report a +lifecycle write failure, retain the in-memory JSON-history model as their +fail-soft read fallback. `internal/alerts/history_projection_parity_test.go` characterizes the event-log projection against that fallback for active, resolved, acknowledged, multi-resource, and repeated occurrences. Live active-alert state overlays the @@ -1038,12 +1048,26 @@ failure leaves JSON history authoritative for the next attempt. An unreadable or malformed source, or any entry that cannot produce an identified complete snapshot, blocks both retirement and further JSON writes so startup cannot silently replace recoverable user history with an empty file. Successful -retirement stops further JSON writes. Clearing history appends a +retirement stops further JSON writes. Retired `.imported` leaves remain bounded +recovery sources: startup loads and idempotently replays them if `events.db` is +recreated, so loss or quarantine of the database cannot turn a successful +migration into silent empty history. Clearing history appends a `history_cleared` tombstone rather than deleting log rows; the projection ignores earlier lifecycle events but overlays still-active alerts as current state. Unified-incident and system-alert activation must emit snapshot-bearing fired events so their projected history does not begin at acknowledgement or resolution. +`SubscribeLifecycleCallback` is the delivery-independent projection seam for +that canonical stream. It runs for lifecycle transitions regardless of +activation, quiet hours, grouping, rate limits, or destination state; delivery +callbacks remain policy-controlled consumers. Monitoring uses this seam to +materialize canonical resource-history breadcrumbs and incident shells. On +startup it replays durable lifecycle transitions oldest first, then +idempotently reconciles restored active alerts that predate the event store. +This repairs resolved as well as active incident timelines without replaying +any delivery side effect, and ensures an alert already visible in Overview +cannot keep returning an unavailable timeline merely because its notification +was held. The same dispatch policy owns firing-notification evidence on active alerts: any alert that passes notification suppression and enters the fired callback fan-out must carry `LastNotified` before the callback clone is emitted. Resolved diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 83934539d..b989c0a8d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5687,6 +5687,13 @@ while `frontend-modern/src/features/alerts/OverviewTab.tsx` and surface composition. Future incident timeline fetch, note-save, or expansion control flow should extend that feature hook rather than forking back into either tab surface. +Every surface invoking that owner must pass the canonical alert identifier and +the occurrence start time as distinct arguments; a row key is local UI state, +not a substitute for either API identity field. +The shared `IncidentTimelinePanel` consumes loading, error, timeline, draft, +and save state as accessors so an asynchronous result remains reactive across +Overview, desktop History, and mobile History instead of freezing the values +present when the panel first expands. Overview alert runtime now follows that same shell-versus-runtime split. The shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while `frontend-modern/src/features/alerts/useAlertOverviewState.ts` owns derived diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 333941e7f..2c60cbc19 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2071,14 +2071,23 @@ rebuilds and supplemental ingest into the unified-resource timeline. That means monitoring no longer just materializes state snapshots for consumers; it also emits durable `ResourceChange` history through the shared resource store so live monitoring updates and historical inspection stay aligned. -That same ownership now includes alert-lifecycle facts emitted by monitoring. -When an alert is fired, acknowledged, unacknowledged, or resolved for a -canonical resource, the monitoring runtime must write the corresponding durable -resource-history event into the unified-resource change store instead of -leaving that lifecycle only inside alert-scoped incident memory. Incident -timelines may still project those breadcrumbs for operator flow, but the -durable backend truth for alert lifecycle now lives on the canonical resource -timeline. +That same ownership now includes the resource-history projection of canonical +alert-lifecycle facts. The alerts-owned SQLite event log is the lifecycle source +of truth; monitoring consumes its delivery-independent lifecycle seam and +materializes fired, acknowledged, unacknowledged, and resolved breadcrumbs in +the unified-resource change store. Projection IDs derive deterministically from +alert identity, canonical resource, transition kind, and occurrence time, so +restart repair and duplicate consumer delivery are idempotent. Notification +activation, quiet hours, grouping, throttling, and destination health may never +gate this projection. Incident timelines project those breadcrumbs for +operator flow, while the resource timeline remains the durable resource-scoped +index rather than a second alert lifecycle authority. +Monitoring must install the lifecycle consumer, replay durable lifecycle +events oldest first, and then reconcile restored active alerts that predate +the event store. Replay and reconciliation create only missing projections, +including a stable `pulse-system` timeline identity for system alerts whose +public alert payload intentionally has no monitored-resource link; neither path +may duplicate a resource change or invoke notification delivery. The monitor-owned incident store wiring must therefore attach the canonical resource timeline reader whenever the unified monitor adapter is present, so operator alert timelines and AI incident context project those lifecycle events diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c271341c8..56d7cd838 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,27 +1,25 @@ { "version": 1, - "base_sha": "3d91709ebad0332858e4615d72daf8e424ab42a8", - "verified_at": "2026-08-27T12:19:53Z", + "base_sha": "1e75423116984e67ca92a8904f913e5e6a194324", + "verified_at": "2026-08-27T13:41:54Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx", - "frontend-modern/src/components/Alerts/ThresholdsTableSMARTDefaultsCard.tsx", - "frontend-modern/src/features/alerts/alertsConfigurationModel.ts", - "frontend-modern/src/types/alerts.ts", - "frontend-modern/src/utils/alertThresholdDefaults.ts" + "frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx", + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx" ], "content_sha256": { - "frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx": "89646984deb982699d3359953ab85a2ceae15830465ccf20b5603f5a426e3193", - "frontend-modern/src/components/Alerts/ThresholdsTableSMARTDefaultsCard.tsx": "c1bd1947dbcbab5806e11fa905271039131cebb0a736642e73fd3652f63e6b66", - "frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "2100da1f350cbbea386f1ae669a655d213ea710f1636eb94fe80c6d47d29313d", - "frontend-modern/src/types/alerts.ts": "cc8b88ec5d8de87dbb69116114e9cd889347972b59dd81bec081096ab791c693", - "frontend-modern/src/utils/alertThresholdDefaults.ts": "6ea4a3f99c8bd27a4fcca98f67c866d06a0f5d3e3211550a58447688cb61352a" + "frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx": "2f32d34e7bfedd2e052cf20075a4eb711d114ee5715c13a108f6b3666f29ad91", + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "d5179ae59605d318f522483b13e5925fd7a6890a066b129eaf769d42b334d2d4", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx": "cc763cdadc2f48e47437cfddf61ebbd5723e74a6a89f3640da013b63f6598cdb", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "fc2822b803ab1c250811e8a374c7cba94ae1f4b08130acf48ba6b98cea3f0a5c" }, - "routes": ["/alerts/thresholds/systems"], + "routes": ["/alerts/overview", "/alerts/history"], "viewports": [ { - "width": 1440, - "height": 1000 + "width": 1280, + "height": 720 }, { "width": 390, @@ -29,17 +27,18 @@ } ], "states": [ - "Machines thresholds loaded with the backward-compatible SMART factory rules", - "failed-health disabled with pending sectors set to 4, life warning set to 15 percent, and NVMe spare critical disabled at 0", - "edited SMART rules persisted after Save Changes and a full route reload", - "factory SMART values restored and saved from the narrow layout", - "complete single-column SMART card inspected through the app scroll shell at 390x844" + "restored active alert visible while notification delivery is pending review", + "canonical incident API returns the occurrence-specific fired event for the active alert", + "expanded Overview timeline renders incident status, fired event, filters, timestamp, and note editor", + "legacy JSON history imported once into SQLite and retained across backend restart", + "history clear tombstone removes retired history while preserving the active alert overlay" ], "interactions": [ - "opened Alerts, selected Thresholds, and selected the Machines platform tab at 1440x1000", - "toggled failed-health, edited counter and percentage rules including an explicit 0 disable value, and saved through the shared dirty-state bar", - "reloaded /alerts/thresholds/systems and confirmed every edited value persisted", - "scrolled the complete SMART rules card at 390x844, confirmed scroll width matched client width, restored factory values, and saved from the narrow layout", - "confirmed labels, help text, controls, percentage suffixes, focusable save actions, and the browser console remained free of errors" + "opened Alerts Overview at 1280x720 and expanded the active alert timeline", + "confirmed the Timeline control requested the canonical alert identifier and occurrence start time exactly once", + "confirmed the rendered fired event replaced the unavailable-timeline state while delivery remained paused", + "reloaded Alerts Overview at 390x844, expanded the same timeline, and confirmed no horizontal overflow", + "restarted the managed real backend twice and confirmed history import, clear tombstone, and active-overlay continuity", + "confirmed the five-test alert operator and real-backend qualification suite passed with no product errors" ] } diff --git a/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx index a4c853c5f..28b16f810 100644 --- a/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx +++ b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx @@ -30,31 +30,31 @@ import { } from '@/utils/alertIncidentPresentation'; export interface IncidentTimelinePanelProps { - timeline?: Incident | null; - loading: boolean; - error: boolean; + timeline: Accessor; + loading: Accessor; + error: Accessor; filters: Accessor>; setFilters: (next: Set) => void; filterVariant: AlertIncidentEventFilterVariant; eventCardVariant: 'surface' | 'alt'; - noteDraft: string; + noteDraft: Accessor; onNoteDraftChange: (value: string) => void; - noteSaving: boolean; + noteSaving: Accessor; onSaveNote: () => void; onRetry: () => void; } export function IncidentTimelinePanel(props: IncidentTimelinePanelProps) { - const timeline = () => props.timeline; + const timeline = props.timeline; const events = createMemo(() => timeline()?.events || []); const filteredEvents = createMemo(() => filterIncidentEvents(events(), props.filters())); return ( <> - +

{getAlertTimelineLoadingState().text}

- + {(loadedTimeline) => (
@@ -121,25 +121,25 @@ export function IncidentTimelinePanel(props: IncidentTimelinePanelProps) { textareaBaseClass={getAlertIncidentNoteTextareaClass()} rows={2} placeholder={getAlertTimelineNotePlaceholder()} - value={props.noteDraft} + value={props.noteDraft()} onInput={(event) => props.onNoteDraftChange(event.currentTarget.value)} />
)}
- + {getAlertTimelineUnavailableState().text}

} >
diff --git a/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx b/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx index 5689b79ab..e5ee72f5a 100644 --- a/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx @@ -55,16 +55,16 @@ describe('IncidentTimelinePanel', () => { render(() => ( state().loading} + error={() => state().error} + timeline={() => state().timeline} filters={filters} setFilters={setFilters} filterVariant="panel" eventCardVariant="alt" - noteDraft="" + noteDraft={() => ''} onNoteDraftChange={vi.fn()} - noteSaving={false} + noteSaving={() => false} onSaveNote={vi.fn()} onRetry={vi.fn()} /> @@ -88,16 +88,16 @@ describe('IncidentTimelinePanel', () => { render(() => ( false} + error={() => false} + timeline={() => makeTimeline()} filters={filters} setFilters={setFilters} filterVariant="compact" eventCardVariant="surface" - noteDraft="operator note" + noteDraft={() => 'operator note'} onNoteDraftChange={handleNoteDraftChange} - noteSaving={false} + noteSaving={() => false} onSaveNote={handleSave} onRetry={vi.fn()} /> @@ -127,16 +127,16 @@ describe('IncidentTimelinePanel', () => { render(() => ( false} + error={() => false} + timeline={() => makeTimeline()} filters={filters} setFilters={setFilters} filterVariant="compact" eventCardVariant="surface" - noteDraft="" + noteDraft={() => ''} onNoteDraftChange={vi.fn()} - noteSaving={false} + noteSaving={() => false} onSaveNote={vi.fn()} onRetry={vi.fn()} /> diff --git a/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx index bc6bed864..3bccbf129 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx @@ -125,16 +125,16 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) { >
props.state.incidentLoading()[rowKey()]} + error={() => props.state.incidentErrors()[rowKey()]} + timeline={() => props.state.incidentTimelines()[rowKey()]} filters={props.state.historyIncidentEventFilters} setFilters={props.state.setHistoryIncidentEventFilters} filterVariant="compact" eventCardVariant="surface" - noteDraft={props.state.incidentNoteDrafts()[rowKey()] || ''} + noteDraft={() => props.state.incidentNoteDrafts()[rowKey()] || ''} onNoteDraftChange={(value) => props.state.setIncidentNoteDraft(rowKey(), value)} - noteSaving={props.state.incidentNoteSaving().has(rowKey())} + noteSaving={() => props.state.incidentNoteSaving().has(rowKey())} onSaveNote={() => { void props.state.saveIncidentNote(rowKey(), alert.id, alert.startTime); }} diff --git a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx index 2d9ec8df7..9187526f5 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx @@ -96,16 +96,16 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) props.state.incidentLoading()[rowKey()]} + error={() => props.state.incidentErrors()[rowKey()]} + timeline={() => props.state.incidentTimelines()[rowKey()]} filters={props.state.historyIncidentEventFilters} setFilters={props.state.setHistoryIncidentEventFilters} filterVariant="compact" eventCardVariant="surface" - noteDraft={props.state.incidentNoteDrafts()[rowKey()] || ''} + noteDraft={() => props.state.incidentNoteDrafts()[rowKey()] || ''} onNoteDraftChange={(value) => props.state.setIncidentNoteDraft(rowKey(), value)} - noteSaving={props.state.incidentNoteSaving().has(rowKey())} + noteSaving={() => props.state.incidentNoteSaving().has(rowKey())} onSaveNote={() => { void props.state.saveIncidentNote(rowKey(), props.alert.id, props.alert.startTime); }} diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 3d7575c5e..f6e4a2d26 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -191,7 +191,11 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {