diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index ed5a3b0e3..a47e21132 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2647,3 +2647,20 @@ The hook and destinations caller regressions in `useAlertDestinationsTabState.test.tsx` pin ordering and loading ownership. `scripts/check-delivery-health-ordering.mjs` exercises the real caller and card in Chromium with scripted API completions; it is not installed delivery proof. + +### Alert status distinguishes dispatch from destination evidence + +The active alert card renders a valid diagnosis `lastNotified` timestamp as +“Dispatch requested”, never “Notified”: the alert manager records this field +before invoking delivery callbacks. A cooldown's `nextEligibleAt` is labelled +“next eligible”, not a promised send time. Neither field proves destination +acceptance or recipient receipt; that evidence must not be inferred from the +muted presentation tone. Missing or invalid timestamps retain the existing +pending/cooldown fallback; acknowledged alerts retain their badge without a +second status line. No API field, notification policy or shared primitive changes. + +The existing wrapping status text must remain readable at desktop and phone +widths despite the longer labels. The presentation and Overview delivery-status +tests cover the evidence boundary; `scripts/check-alert-dispatch-copy.mjs` +qualifies the real Overview with scripted API data in Chromium, not installed +notification delivery. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 47f916c66..19d0e4991 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7154,3 +7154,20 @@ confirmation and wrapping controls remain unchanged; no new primitive is added. The focused hook/caller tests and `scripts/check-delivery-health-ordering.mjs` cover this dependency at desktop and narrow widths using scripted health and queue-action responses, without claiming backend notification delivery. + +### Alert status distinguishes dispatch from destination evidence + +The active alert card renders a valid diagnosis `lastNotified` timestamp as +“Dispatch requested”, never “Notified”: the alert manager records this field +before invoking delivery callbacks. A cooldown's `nextEligibleAt` is labelled +“next eligible”, not a promised send time. Neither field proves destination +acceptance or recipient receipt; that evidence must not be inferred from the +muted presentation tone. Missing or invalid timestamps retain the existing +pending/cooldown fallback; acknowledged alerts retain their badge without a +second status line. No API field, notification policy or shared primitive changes. + +The existing wrapping status text must remain readable at desktop and phone +widths despite the longer labels. The presentation and Overview delivery-status +tests cover the evidence boundary; `scripts/check-alert-dispatch-copy.mjs` +qualifies the real Overview with scripted API data in Chromium, not installed +notification delivery. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a942fbaa7..5e8b35ad1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,16 @@ { "version": 1, - "base_sha": "9b4fa52d84e6196108e961ccba2b317df497268b", - "verified_at": "2026-09-06T10:15:18.709765Z", + "base_sha": "4fd68c72b26f033f5b1b0fcb7d7b2b0d67e85ec8", + "verified_at": "2026-09-06T14:12:06.315693Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/AI/Chat/ChatMessages.tsx", - "frontend-modern/src/components/AI/Chat/hooks/useChat.ts" + "frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", - "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c" + "frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607" }, "routes": [ - "/patrol" + "/qualification (isolated real OverviewTab, not installed /alerts)" ], "viewports": [ { @@ -29,12 +27,9 @@ } ], "states": [ - "Captured healthy and dependency Assistant responses replayed without provider calls. All 7 and 16 terminal tool records retain exact inputs, outputs and distinct rows. Success, failed NO_AGENT and unsupported config results inspected.", - "Controlled stream: concurrent same-name calls, repeated starts, progress, two separate approval cards, sibling completion, canceled call and failed completion. No real action was attempted.", - "Restored persisted healthy and dependency sessions retain original prompts and evidence. Artificial route warning reflects blocked non-GET readiness checks, not route qualification." + "Scripted ready and cooldown diagnoses with lastNotified render Dispatch requested; cooldown says next eligible. Missing timestamp remains Notification pending. No Notified label." ], "interactions": [ - "Hover, focus, Enter/Space expand and collapse, scroll expanded output to its end, Escape, reload and select the original persisted session. Tool details and wrapping inspected at desktop, intermediate and narrow widths.", - "Private evidence: /Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/identity-verified-healthy, identity-verified-fault and identity-states. Baseline reproductions retained in identity-before-fault and failed focused regressions." + "Loaded real Overview in Chromium via scripts/check-alert-dispatch-copy.mjs; asserted status text ranges fit each viewport and no page errors. Inspected desktop and phone screenshots. No delivery actions invoked; no backend receipt claimed. Screenshots retained in lane outcome evidence." ] } diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx index 995a149a4..631ae1590 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx @@ -111,6 +111,19 @@ describe('OverviewTab delivery status line', () => { expect(getDeliveryDiagnoses).toHaveBeenCalled(); }); + it.each([ + { status: 'would_send', reason: 'ready' }, + { status: 'suppressed', reason: 'cooldown', nextEligibleAt: '2026-08-26T10:20:00Z' }, + ] as const)('does not turn dispatch evidence into receipt for $reason', async (state) => { + getDeliveryDiagnoses.mockResolvedValue([ + makeDiagnosis('a1', { ...state, lastNotified: '2026-08-26T10:15:00Z' }), + ]); + render(() => ); + await waitFor(() => expect(screen.getByText(/^Dispatch requested /)).toBeTruthy()); + expect(screen.queryByText(/^Notified /)).toBeNull(); + if (state.reason === 'cooldown') expect(screen.getByText(/next eligible/)).toBeTruthy(); + }); + it('renders no delivery line when the diagnosis fetch fails', async () => { const activeAlerts: Record = { a1: makeAlert('a1') }; getDeliveryDiagnoses.mockRejectedValue(new Error('boom')); diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx index dedd034d3..1309b153d 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n'; import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; @@ -75,6 +75,37 @@ describe('OverviewTab Last 24 Hours stat', () => { setActiveLocale(DEFAULT_LOCALE); }); + it('keeps dispatch evidence separate from the triggered count', async () => { + vi.useRealTimers(); + const now = Date.now(); + getDeliveryDiagnoses.mockResolvedValue([ + { + alertIdentifier: 'old', + alertId: 'old', + status: 'would_send', + reason: 'ready', + lastNotified: new Date(now).toISOString(), + } as AlertDeliveryDiagnosis, + ]); + render(() => ( + + )); + await waitFor(() => expect(screen.getByText(/^Dispatch requested /)).toBeTruthy()); + expect(screen.queryByText(/^Notified /)).toBeNull(); + expect( + screen + .getByText('Triggered (24h)') + .closest('tr') + ?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent, + ).toBe('0'); + }); + it('counts only alerts with startTime within the last 24 hours', () => { const now = Date.now(); const oneHourAgo = new Date(now - 3_600_000).toISOString(); diff --git a/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts index ecc7be09c..c5c4f53fd 100644 --- a/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts @@ -35,13 +35,45 @@ describe('describeAlertDeliveryStatus', () => { expect(describeAlertDeliveryStatus(diagnosis, true)).toBeNull(); }); - it('shows the notified time when eligible and already notified', () => { + it('shows dispatch evidence without claiming destination success', () => { const diagnosis = baseDiagnosis({ lastNotified: '2026-08-26T10:15:00Z' }); const line = describeAlertDeliveryStatus(diagnosis, false); expect(line?.tone).toBe('muted'); - expect(line?.label).toMatch(/^Notified /); + expect(line?.label).toMatch(/^Dispatch requested /); }); + it('shows dispatch without promising another send when cooldown has no next time', () => { + const line = describeAlertDeliveryStatus( + baseDiagnosis({ + status: 'suppressed', + reason: 'cooldown', + lastNotified: '2026-08-26T10:15:00Z', + }), + false, + ); + expect(line?.label).toMatch(/^Dispatch requested /); + expect(line?.label).not.toContain('next'); + }); + + it.each([undefined, '', 'invalid'])( + 'does not invent dispatch for timestamp %s', + (lastNotified) => { + expect(describeAlertDeliveryStatus(baseDiagnosis({ lastNotified }), false)?.label).toBe( + 'Notification pending', + ); + expect( + describeAlertDeliveryStatus( + baseDiagnosis({ + status: 'suppressed', + reason: 'cooldown', + lastNotified, + }), + false, + )?.label, + ).toBe('Waiting for cooldown'); + }, + ); + it('shows pending when eligible but never notified', () => { const line = describeAlertDeliveryStatus(baseDiagnosis({}), false); expect(line).toEqual({ label: 'Notification pending', tone: 'muted' }); @@ -67,7 +99,7 @@ describe('describeAlertDeliveryStatus', () => { }); const line = describeAlertDeliveryStatus(diagnosis, false); expect(line?.tone).toBe('muted'); - expect(line?.label).toMatch(/^Notified .* — next /); + expect(line?.label).toMatch(/^Dispatch requested .* — next eligible /); }); it.each([ diff --git a/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts index b5ca69e95..33db25b55 100644 --- a/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts +++ b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts @@ -54,9 +54,11 @@ export const describeAlertDeliveryStatus = ( const reason = (diagnosis.reason || '').split(':')[0]; + // lastNotified is recorded before delivery callbacks; it is dispatch evidence, + // not confirmation that a destination accepted or a person received anything. if (diagnosis.status === 'would_send') { const notifiedAt = formatShortTime(diagnosis.lastNotified); - if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' }; + if (notifiedAt) return { label: `Dispatch requested ${notifiedAt}`, tone: 'muted' }; return { label: 'Notification pending', tone: 'muted' }; } @@ -75,9 +77,12 @@ export const describeAlertDeliveryStatus = ( const notifiedAt = formatShortTime(diagnosis.lastNotified); const nextAt = formatShortTime(diagnosis.nextEligibleAt); if (notifiedAt && nextAt) { - return { label: `Notified ${notifiedAt} — next ${nextAt}`, tone: 'muted' }; + return { + label: `Dispatch requested ${notifiedAt} — next eligible ${nextAt}`, + tone: 'muted', + }; } - if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' }; + if (notifiedAt) return { label: `Dispatch requested ${notifiedAt}`, tone: 'muted' }; return { label: 'Waiting for cooldown', tone: 'muted' }; } case 'rate_limited': diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index 7c61f5146..b357d668b 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -343,8 +343,28 @@ func TestLifecycleReplayMaterializesImportedHistoryTimeline(t *testing.T) { monitor := &Monitor{alertManager: manager, incidentStore: incidentStore} resourceStore := unifiedresources.NewMemoryStore() adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(resourceStore)) - monitor.SetResourceStore(adapter) - monitor.SetResourceStore(adapter) + // Hold replay at its serialization boundary. Router construction attaches + // this store, so attachment must return even while history repair cannot + // make progress. Eventual timeline assertions alone miss a synchronous + // replay regression that stalls startup on an upgrade backlog. + monitor.alertProjectionReplayMu.Lock() + attached := make(chan struct{}) + go func() { + monitor.SetResourceStore(adapter) + monitor.SetResourceStore(adapter) + close(attached) + }() + select { + case <-attached: + monitor.alertProjectionReplayMu.Unlock() + case <-time.After(2 * time.Second): + // Release the probe before failing, including for a synchronous-replay + // negative control, so no goroutine retains the test's stores. + monitor.alertProjectionReplayMu.Unlock() + <-attached + monitor.alertProjectionWG.Wait() + t.Fatal("resource-store attachment waited for lifecycle replay") + } monitor.alertProjectionWG.Wait() timeline := incidentStore.GetTimelineByAlertAt(snapshot.ID, snapshot.StartTime) diff --git a/scripts/check-alert-dispatch-copy.mjs b/scripts/check-alert-dispatch-copy.mjs new file mode 100644 index 000000000..c3daecccb --- /dev/null +++ b/scripts/check-alert-dispatch-copy.mjs @@ -0,0 +1,123 @@ +// Isolated real-browser component qualification; no installed backend or delivery claim. +import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js"; +import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs"; +import { chromium } from "@playwright/test"; +import { resolve } from "node:path"; +import { mkdirSync } from "node:fs"; +import assert from "node:assert/strict"; +const root = resolve("frontend-modern"); +process.chdir(root); +const fixture = ` +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { AlertsAPI } from '/src/api/alerts'; +import { NotificationsAPI } from '/src/api/notifications'; +import { OverviewTab } from '/src/features/alerts/OverviewTab'; +import '/src/index.css'; +const ids = ['ready','cooldown','pending']; +AlertsAPI.getDeliveryDiagnoses = async () => ids.map(id => ({ +alertIdentifier:id, alertId:id, trackingKey:id, status:id==='cooldown'?'suppressed':'would_send', +reason:id==='cooldown'?'cooldown':'ready', lastNotified:id==='pending'?undefined:'2026-08-26T10:15:00Z', +nextEligibleAt:id==='cooldown'?'2026-08-26T10:20:00Z':undefined +})); +AlertsAPI.getEvents = async () => []; +NotificationsAPI.getHealth = async () => ({queue:{status:'healthy'}}); +const alerts = Object.fromEntries(ids.map(id => [id, {id,resourceId:id,resourceName:'VM '+id, +type:'cpu',level:'warning',message:'High CPU on '+id,startTime:'2026-08-26T10:00:00Z',acknowledged:false,node:'node1'}])); +function Fixture() { return
{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true} +setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/>
; } +render(()=>,document.getElementById('root')); +`; +const server = await createServer({ + root, + configFile: false, + optimizeDeps: { + noDiscovery: true, + entries: [], + esbuildOptions: { target: "esnext" }, + }, + esbuild: { target: "esnext" }, + plugins: [ + solid(), + { + name: "dispatch-fixture", + configureServer(s) { + s.middlewares.use((req, res, next) => { + if (req.url === "/qualification") { + res.setHeader("Content-Type", "text/html"); + res.end( + '
', + ); + } else next(); + }); + }, + resolveId(id) { + if (id === "/dispatch-fixture.tsx") return id; + }, + load(id) { + if (id === "/dispatch-fixture.tsx") return fixture; + }, + }, + ], + resolve: { alias: { "@": resolve(root, "src") } }, + server: { host: "127.0.0.1", port: 5198, strictPort: true }, +}); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true }); + mkdirSync("/tmp/pulse-alert-dispatch", { recursive: true }); + for (const width of [1440, 900, 390]) { + const page = await browser.newPage({ viewport: { width, height: 1000 } }); + const errors = []; + page.on("pageerror", (e) => { + errors.push(e.message); + console.error(e.message); + }); + page.on("console", (m) => { + if (m.type() === "error") console.error(m.text()); + }); + await page.route("http://127.0.0.1:5198/api/**", (route) => + route.fulfill({ json: [] }), + ); + await page.goto("http://127.0.0.1:5198/qualification"); + await page.getByText(/^Dispatch requested .*next eligible/).waitFor(); + assert.equal(await page.getByText(/^Dispatch requested /).count(), 2); + assert.equal(await page.getByText(/^Notified /).count(), 0); + assert.equal( + await page.getByText("Notification pending", { exact: true }).count(), + 1, + ); + for (const label of await page.getByText(/^Dispatch requested /).all()) { + assert.equal( + await label.evaluate((el) => { + const r = document.createRange(); + r.selectNodeContents(el); + return [...r.getClientRects()].every( + (b) => b.left >= 0 && b.right <= innerWidth, + ); + }), + true, + "status text must fit viewport", + ); + } + assert.deepEqual(errors, []); + await page.screenshot({ + path: "/tmp/pulse-alert-dispatch/" + width + ".png", + fullPage: true, + }); + await page.close(); + } + console.log( + JSON.stringify({ + result: "passed", + viewports: [1440, 900, 390], + scope: + "Real Overview and Chromium; scripted diagnoses, not installed delivery or receipt", + }), + ); +} finally { + await browser?.close(); + await server.close(); +}