diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 37cb71d26..cc60f3d6d 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,12 @@ ## Purpose +The alerts overview offers the existing delivery-status refresh control when +health is unavailable, including after a successful retained-queue action whose +follow-up health read fails. The warning remains until a verified healthy read; +a successful queue action alone is not evidence of delivery health. Normal +degraded summary presentation continues to omit refresh. + Confirmed canonical metric recovery publishes the clearing evaluation's value, observation time, and resolved metric wording in the snapshot consumed by recent-resolution reads and notification callbacks. It must not reuse the last diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 26a89168b..0828f81b6 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,12 @@ ## Purpose +The alerts overview offers the existing delivery-status refresh control when +health is unavailable, including after a successful retained-queue action whose +follow-up health read fails. The warning remains until a verified healthy read; +a successful queue action alone is not evidence of delivery health. Normal +degraded summary presentation continues to omit refresh. + Proxmox backup presentation treats every manifestless PBS artifact as non-recoverable. It renders the artifact as `Running` when current writer visibility is absent or a matching writer is active, and as danger-tone diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c18e0751c..357d04b34 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,16 @@ { "version": 1, - "base_sha": "2a833eccdfecf7248eed63eabf5b9184a9fa2390", - "verified_at": "2026-09-05T23:57:09.414504Z", + "base_sha": "4cc53d10950f2effae720d251f92bf7d143d2093", + "verified_at": "2026-09-06T00:55:06.543758Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts" + "frontend-modern/src/features/alerts/OverviewTab.tsx" ], "content_sha256": { - "frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts": "1eadf6df30b4f1130f868b8f139561ea60fdb87e1823deeeaaf2f8fbc699a00c" + "frontend-modern/src/features/alerts/OverviewTab.tsx": "8f7fdc04bd0546f86152c8bb392ff3e3fb755f1f0a1c28d1ee85da3e9fe23582" }, "routes": [ - "/qualification (isolated caller/card fixture, not application routing)" + "/qualification (actual OverviewTab in isolated Solid Router fixture; not application shell)" ], "viewports": [ { @@ -23,12 +23,11 @@ } ], "states": [ - "Real Chromium with current useAlertDestinationsTabState, useNotificationDeliveryHealth and AlertDeliveryHealthCard; scripted API promises and queue actions. No installed backend or provider receipt.", - "Older healthy/newer degraded, older degraded/newer healthy, older error/newer healthy, older healthy/newer error. Warning presence and rendered state remain owned by newer request. Desktop and narrow screenshots inspected; this is not a full-shell or accessibility audit." + "Actual OverviewTab and delivery-health hook/card in Chromium, scripted API promises, empty active alerts. Not installed backend or recipient qualification.", + "Degraded health, successful Retry or Dismiss followed by unavailable health, pending manual refresh, verified healthy response. Desktop and narrow unavailable screenshots visually inspected; Refresh fits without clipping. Not a full accessibility or shell audit." ], "interactions": [ - "pulse-heavy-run -- node scripts/check-delivery-health-ordering.mjs: 12 cases passed.", - "Configuration Retry overlaps pending mount health. Resolve newer then older and compare rendered main text and alert presence.", - "Retry retained deliveries and Dismiss retained failures each start post-action refresh while another read is pending. Older completion leaves loading true; latest healthy response clears warning. Confirmations and API responses are scripted." + "pulse-heavy-run -- node scripts/check-overview-health-refresh.mjs: four cases passed (two actions at two widths).", + "Retry and Dismiss each accepted once; failed health read preserves unavailable warning and offers Refresh; Refresh disabled while pending; healthy read removes warning without another queue action." ] } diff --git a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx index 9f3828c05..2651c01da 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx @@ -103,6 +103,26 @@ describe('AlertDeliveryHealthCard', () => { expect(screen.getByRole('button', { name: 'Refresh delivery status' })).toBeDisabled(); }); + it('allows unavailable summary health to be rechecked without a queue mutation', () => { + const onRefresh = vi.fn(); + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Notification delivery status is unavailable', + ); + fireEvent.click(screen.getByRole('button', { name: 'Refresh delivery status' })); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(screen.getByRole('alert')).toBeTruthy(); + }); + it('keeps the overview treatment concise and points directly to delivery evidence', () => { render(() => ( diff --git a/frontend-modern/src/features/alerts/OverviewTab.tsx b/frontend-modern/src/features/alerts/OverviewTab.tsx index 219aeb25e..de8319f39 100644 --- a/frontend-modern/src/features/alerts/OverviewTab.tsx +++ b/frontend-modern/src/features/alerts/OverviewTab.tsx @@ -90,7 +90,7 @@ export function OverviewTab(props: { onDismissFailures={() => void deliveryHealthState.dismissTerminalFailures()} detailsHref="/alerts/notifications#notification-delivery-activity" detailLevel="summary" - showRefresh={false} + showRefresh={deliveryHealthState.deliveryHealthUnavailable()} /> diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliveryactions.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliveryactions.test.tsx index 1851cf704..f7724875b 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliveryactions.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliveryactions.test.tsx @@ -164,15 +164,58 @@ describe('OverviewTab delivery health actions', () => { expect(screen.getByRole('button', { name: action.name })).not.toBeDisabled(); }); + it(`keeps health visibly unknown after ${action.name} succeeds but refresh fails`, async () => { + getHealth + .mockResolvedValueOnce(degradedHealth()) + .mockRejectedValueOnce(new Error('health unavailable')); + action.api.mockResolvedValue({ affected: 85 }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: action.name })); + + const refresh = await screen.findByRole('button', { name: 'Refresh delivery status' }); + expect(screen.getByRole('alert')).toBeTruthy(); + expect(notificationStore.success).toHaveBeenCalledOnce(); + expect(notificationStore.error).not.toHaveBeenCalled(); + + const healthy = degradedHealth(); + healthy.overallHealthy = true; + healthy.queue = { + ...healthy.queue, + status: 'healthy', + healthy: true, + attentionRequired: 0, + deadLetter: 0, + }; + getHealth.mockResolvedValueOnce(healthy); + await waitFor(() => expect(refresh).not.toBeDisabled()); + fireEvent.click(refresh); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + expect(getHealth).toHaveBeenCalledTimes(3); + expect(action.api).toHaveBeenCalledOnce(); + }); + it(`keeps both actions disabled until ${action.name} and its health refresh finish`, async () => { const healthy = degradedHealth(); healthy.overallHealthy = true; - healthy.queue = { ...healthy.queue, status: 'healthy', healthy: true, attentionRequired: 0, deadLetter: 0 }; + healthy.queue = { + ...healthy.queue, + status: 'healthy', + healthy: true, + attentionRequired: 0, + deadLetter: 0, + }; let completeAction!: (value: { affected: number }) => void; let completeHealth!: (value: NotificationHealth) => void; - action.api.mockReturnValue(new Promise(resolve => { completeAction = resolve; })); + action.api.mockReturnValue( + new Promise((resolve) => { + completeAction = resolve; + }), + ); getHealth.mockResolvedValueOnce(degradedHealth()).mockReturnValueOnce( - new Promise(resolve => { completeHealth = resolve; }), + new Promise((resolve) => { + completeHealth = resolve; + }), ); vi.spyOn(window, 'confirm').mockReturnValue(true); render(() => ); @@ -197,5 +240,4 @@ describe('OverviewTab delivery health actions', () => { expect(notificationStore.error).not.toHaveBeenCalled(); }); } - }); diff --git a/scripts/check-overview-health-refresh.mjs b/scripts/check-overview-health-refresh.mjs new file mode 100644 index 000000000..494d67802 --- /dev/null +++ b/scripts/check-overview-health-refresh.mjs @@ -0,0 +1,98 @@ +// 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 { NotificationsAPI } from '/src/api/notifications'; +import { AlertsAPI } from '/src/api/alerts'; +import { OverviewTab } from '/src/features/alerts/OverviewTab'; +import '/src/index.css'; +const pending = []; +NotificationsAPI.getHealth = () => new Promise((resolve, reject) => pending.push({resolve, reject})); +AlertsAPI.getDeliveryDiagnoses = async () => []; +window.actions = 0; +NotificationsAPI.retryTerminalFailures = NotificationsAPI.dismissTerminalFailures = async () => { window.actions++; return {affected: 1}; }; +window.confirm = () => true; +window.finish = (i, status) => status === 'error' ? pending[i].reject(new Error('scripted offline')) : pending[i].resolve({queue:{status, failed:0, deadLetter:status === 'healthy' ? 0 : 1, attentionRequired:status === 'healthy' ? 0 : 1}}); +window.count = () => pending.length; +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: "ordering-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 === "/ordering-fixture.tsx") return id; + }, + load(id) { + if (id === "/ordering-fixture.tsx") return fixture; + }, + }, + ], + resolve: { alias: { "@": resolve(root, "src") } }, + server: { host: "127.0.0.1", port: 5197, strictPort: true }, +}); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true }); + mkdirSync('/tmp/pulse-overview-refresh', { recursive: true }); + let cases = 0; + for (const width of [1440, 390]) { + for (const action of ['Retry retained deliveries', 'Dismiss retained failures']) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.goto('http://127.0.0.1:5197/qualification'); + await page.waitForFunction(() => window.count?.() === 1); + await page.evaluate(() => window.finish(0, 'degraded')); + await page.getByRole('button', {name: action, exact: true}).click(); + await page.waitForFunction(() => window.count() === 2); + await page.evaluate(() => window.finish(1, 'error')); + const refresh = page.getByRole('button', {name:'Refresh delivery status', exact:true}); + await refresh.waitFor(); + assert.match(await page.getByRole('alert').innerText(), /status is unavailable/); + await page.screenshot({path: `/tmp/pulse-overview-refresh/${width}-${cases}-unavailable.png`}); + await refresh.click(); + await page.waitForFunction(() => window.count() === 3); + assert.equal(await refresh.isDisabled(), true); + await page.evaluate(() => window.finish(2, 'healthy')); + await page.getByRole('alert').waitFor({state:'detached'}); + assert.equal(await page.evaluate(() => window.actions), 1); + await page.screenshot({path: `/tmp/pulse-overview-refresh/${width}-${cases}-healthy.png`}); + cases++; + await page.close(); + } + } + console.log(`${cases} overview action/outage/refresh browser cases passed`); +} finally { + await browser?.close(); + await server.close(); +}