mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1938 from rcourtman/maintainer/20260906T150036Z
Show alert dispatch honestly and guard responsive startup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(() => <OverviewTab {...defaultProps({ activeAlerts: { a1: makeAlert('a1') } })} />);
|
||||
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<string, Alert> = { a1: makeAlert('a1') };
|
||||
getDeliveryDiagnoses.mockRejectedValue(new Error('boom'));
|
||||
|
||||
@@ -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(() => (
|
||||
<OverviewTab
|
||||
{...defaultProps({
|
||||
activeAlerts: {
|
||||
old: makeAlert('old', new Date(now - 3 * 86_400_000).toISOString()),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
||||
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();
|
||||
|
||||
+35
-3
@@ -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([
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <main class="p-4"><OverviewTab overrides={[]} activeAlerts={alerts}
|
||||
updateAlert={()=>{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true}
|
||||
setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/></main>; }
|
||||
render(()=><Router><Route path="/qualification" component={Fixture}/></Router>,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(
|
||||
'<div id="root"></div><script type="module" src="/dispatch-fixture.tsx"></script>',
|
||||
);
|
||||
} 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();
|
||||
}
|
||||
Reference in New Issue
Block a user