From d04f368f6f2abc6182adb6f33c73c019ea0cf79a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:47:28 +0100 Subject: [PATCH] fix(alerts): allow overview health refresh after recovery outage Retry or Dismiss can succeed while the subsequent health request fails. Offer the existing refresh action when overview health is unavailable so users can verify recovery in place. Two regression cases fail without the fix; 24 focused tests pass with it. API mocks do not qualify installed notification delivery. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 6 ++ .../subsystems/frontend-primitives.md | 6 ++ frontend-modern/browser-verification.json | 19 ++-- .../alerts/AlertDeliveryHealthCard.test.tsx | 20 ++++ .../src/features/alerts/OverviewTab.tsx | 2 +- .../OverviewTab.deliveryactions.test.tsx | 50 +++++++++- scripts/check-overview-health-refresh.mjs | 98 +++++++++++++++++++ 7 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 scripts/check-overview-health-refresh.mjs 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(); +}