mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(() => (
|
||||
<AlertDeliveryHealthCard
|
||||
health={null}
|
||||
unavailable
|
||||
refreshing={false}
|
||||
onRefresh={onRefresh}
|
||||
detailLevel="summary"
|
||||
showRefresh
|
||||
/>
|
||||
));
|
||||
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(() => (
|
||||
<Router>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</Show>
|
||||
<AlertOverviewStatsCards state={overviewState} />
|
||||
|
||||
@@ -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(() => <OverviewTab {...defaultProps()} />);
|
||||
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(() => <OverviewTab {...defaultProps()} />);
|
||||
@@ -197,5 +240,4 @@ describe('OverviewTab delivery health actions', () => {
|
||||
expect(notificationStore.error).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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 <main class="p-4"><OverviewTab overrides={[]} activeAlerts={{}} 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: "ordering-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="/ordering-fixture.tsx"></script>',
|
||||
);
|
||||
} 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();
|
||||
}
|
||||
Reference in New Issue
Block a user