fix(alerts): preserve warning-level notification preferences

Retain warning severity through email API save/reload and webhook editing instead of coercing it to all. Add adapter round-trip and webhook edit/save regressions for issue #2069 with alerts, API and notifications contracts. Browser verification covers production components at desktop and narrow widths using synthetic settings; backend filtering is unchanged.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-14 22:36:28 +01:00
parent ce1e99ba4e
commit e8bbda6f71
9 changed files with 87 additions and 64 deletions
@@ -2936,3 +2936,11 @@ retention, alert freshness or the existing persistence durability contract.
starting database workers, including restart loading and Unix hardening. The
active mirror's unchanged fast path alone does not cover this companion file.
This is a narrow write contributor, not aggregate installed-write acceptance.
### Warning-level destination preference persistence
Email API decoding and encoding, plus webhook edit/create and list presentation,
preserve all three supported minimum-severity values: all, warning, critical.
Unknown values still fall back to all. The email adapter round-trip matrix and
webhook edit/save regression cover issue #2069 without altering delivery filters
or sending provider notifications. Component proof is not installed delivery.
@@ -10828,3 +10828,11 @@ gates retain the explicit credential's normal validation and scopes. In
particular, runner activation and self-revocation continue to require the exact
credential-bound host and tenant, including in the managed development runtime.
Invalid explicit credentials cannot fall back to the convenience bypass.
### Notification severity round trips
Email management payload mapping preserves all, warning and critical in both
GET decoding and PUT encoding. Unknown inputs retain the all fallback. This
repairs a dropped supported value without introducing a new wire enum or changing
backend routing. The notification adapter save/reload matrix verifies each
supported severity in frontend-modern/src/api/__tests__/notifications.test.ts.
@@ -910,3 +910,11 @@ destination enablement. Resolution independently cancels obsolete firing work.
initial-target edit with an in-memory SMTP acceptance, and prevents a previously
fetched pending item from sending after cancellation. This does not establish
recipient inbox timing or recall mail already accepted by a provider.
### Warning-level destination controls
Email settings and webhook edit/create/list controls retain warning as distinct
from all and critical. A warning selection must not become all through frontend
normalization. Existing backend severity admission and delivery queues remain
unchanged. The notification API adapter round trips and WebhookConfig edit/save
regression establish frontend control preservation, not provider delivery.
+20 -59
View File
@@ -1,83 +1,44 @@
{
"version": 1,
"base_sha": "5a4163ff83890005142ed81b025b8dea2a755b56",
"verified_at": "2026-09-12T16:22:19.408045Z",
"base_sha": "ce1e99ba4e37b735d22158ac74a93afa26d7611f",
"verified_at": "2026-09-14T21:33:45.967568Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
"frontend-modern/src/components/Workloads/workloadTopology.ts",
"frontend-modern/src/components/shared/contextualFocus.ts",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
"frontend-modern/src/api/notifications.ts",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "8f0befdd56d332c9fb9e74319b8a7cb3db6fcb5518cd6dbc560f0710d83bd471",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts": "d4e44b1410578876832ae8ba205b5e212154b8b940baa7f42bf72c4b5611ccde",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "b669aed8d88287230a72d98d6916ed77c51455ea0c68fbda3d0623a66859944f",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "1d515d140099a05219f97773b988d065bc5784d95910626f0e797ff3acd1e283",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts": "fb8d8d45ee9acce9359efefb2778a7a246946c84da25f502cbddb398d0ff02a6",
"frontend-modern/src/components/Workloads/workloadTopology.ts": "0917dfb1d9e7b1d65e3ff3afebc9a725b68d40188177fe7181547aebc9b1c3c7",
"frontend-modern/src/components/shared/contextualFocus.ts": "2c83a1ab5ca16c45d0c8a90bbdc0a7d75aedb1c1ec46b5513e31fbc26dfcab2e",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "76a2ccdbfa460492f4eb3b6cae91bda72d15915c6b172056c987e18a11116c94",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "33c6804b64cbacc0e770aabdd162a3e5121e0db5947d91fde95297c621772459",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "d4c7072f66a8a8ed50d920a18982ffef2ffca1fd972daa6471d3c0fe5857ef24"
"frontend-modern/src/api/notifications.ts": "6eb5080070913baa025b07ffef1c38f3297ddfce8095ff71b6297b7e2cf09892",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx": "0830ab5c9f7e0173dbdf3e92ed0e0c9cbf0a34942edb58001a3220d43b39d6c3",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "e79778f3e0b85536c672509210a81d258f8f8e483d09f5ef295118d428cf7a70"
},
"routes": [
"/proxmox/overview"
"/browser-tests/severity.html"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 1440,
"height": 700
},
{
"width": 1024,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Default inventory and inert node hover",
"Selected and cleared node with matching guest count",
"Empty node and type-filtered empty result",
"Node and guest details expanded independently",
"Restored node selection after reload",
"Mobile filters expanded and collapsed",
"Node name default and hover without link decoration",
"Keyboard focus ring without underline",
"Selected and unselected guest scope",
"Separate desktop external website link"
"Existing warning email and webhook",
"All, critical and warning severity saved and reloaded",
"Cancelled webhook edit",
"New warning webhook persisted"
],
"interactions": [
"Click node name, count and plain row cell to filter guests",
"Activate the node name with Enter and Space, and select with touch",
"Reveal and focus the guest heading when it starts below the viewport",
"Expand and collapse node details with the chevron without changing guest scope",
"Keep external links independent of row selection",
"Switch nodes and activate the selected node again to clear its scope with pointer, keyboard and touch",
"Preserve search, status and type filters while selecting and clearing nodes",
"Open guest details and dismiss with Escape while retaining node scope",
"Inspect desktop, intermediate and mobile pixels, including touch controls and empty results",
"Assert the selected node keeps its screen position and keyboard focus when deselected, including with a long open details panel",
"Select and switch minipc, delly and delly2 with an already visible guest heading and assert unchanged node screen position at all four viewports",
"Hover node name and inspect computed decoration and actual pixels",
"Tab and Shift+Tab to node name and inspect visible keyboard focus ring",
"Enter selects guests and Space clears node scope",
"Touch selects and clears the node on phone",
"Verify external website icon remains an independent link"
"Change production email severity selector, save through NotificationsAPI to synthetic backend, reload page and assert each supported value",
"Edit production webhook, save and reload each supported severity",
"Cancel changed webhook severity and verify original warning preserved",
"Create warning webhook and reload synthetic persisted store",
"Inspect desktop edit and narrow saved screenshots"
],
"command": "Playwright scripts check.cjs, adjacent.cjs, reveal.cjs, deselect.cjs and selection-scroll.cjs against the current local Vite server",
"notes": "Live inventory. Final source hashes match the browser-verified implementation. Screenshots and interaction scripts retained locally in the proxmox-node-row-review receipt directory. Rebased onto current main with byte-identical verified runtime files. Final presentation follow-up removes the node-name hover underline. The affected hover, focus, selection and touch matrix passed at1440x1000 and390x844, with other runtime content unchanged."
"command": "pulse-worker-browser severity-browser.cjs",
"notes": "Production-component fixture built with Vite esnext and project CSS. Synthetic email HTTP responses and webhook localStorage persistence; no live backend, notification delivery or whole-application navigation proof. Runtime source hashes match the verified implementation. Desktop edit and narrow saved screenshots inspected."
}
@@ -424,3 +424,20 @@ describe('NotificationsAPI', () => {
expect(result.status).toBe('success');
});
});
describe('email minimum severity save/reload (#2069)', () => {
it.each(['all', 'warning', 'critical'] as const)('preserves %s through the API adapter', async (minimumSeverity) => {
const mock = vi.mocked(apiFetchJSON);
mock.mockReset();
mock.mockResolvedValueOnce({ minimumSeverity });
const loaded = await NotificationsAPI.getEmailConfig();
expect(loaded.minimumSeverity).toBe(minimumSeverity);
mock.mockResolvedValueOnce({ success: true });
await NotificationsAPI.updateEmailConfig(loaded);
const saved = JSON.parse(String(mock.mock.calls[1][1]?.body));
expect(saved.minimumSeverity).toBe(minimumSeverity);
mock.mockResolvedValueOnce(saved);
expect((await NotificationsAPI.getEmailConfig()).minimumSeverity).toBe(minimumSeverity);
});
});
+6 -2
View File
@@ -437,7 +437,9 @@ export class NotificationsAPI {
if (typeof backendConfig.tagFilterMode === 'string') {
config.tagFilterMode = backendConfig.tagFilterMode === 'any' ? 'any' : 'all';
}
config.minimumSeverity = backendConfig.minimumSeverity === 'critical' ? 'critical' : 'all';
config.minimumSeverity = backendConfig.minimumSeverity === 'critical' || backendConfig.minimumSeverity === 'warning'
? backendConfig.minimumSeverity
: 'all';
return config;
}
@@ -467,7 +469,9 @@ export class NotificationsAPI {
backendConfig.tagFilterMode = config.tagFilterMode === 'any' ? 'any' : 'all';
}
if (config.minimumSeverity !== undefined) {
backendConfig.minimumSeverity = config.minimumSeverity === 'critical' ? 'critical' : 'all';
backendConfig.minimumSeverity = config.minimumSeverity === 'critical' || config.minimumSeverity === 'warning'
? config.minimumSeverity
: 'all';
}
return apiFetchJSON(`${this.baseUrl}/email`, {
@@ -711,6 +711,17 @@ describe('WebhookConfig', () => {
expect(urlInput.value).toBe('https://edit.example.com');
});
it('preserves warning severity when editing and saving a webhook', () => {
const webhook = makeWebhook({ minimumSeverity: 'warning' });
render(() => (
<WebhookConfig webhooks={[webhook]} onAdd={onAddMock} onUpdate={onUpdateMock}
onDelete={onDeleteMock} onTest={onTestMock} />
));
fireEvent.click(screen.getByText('Edit'));
fireEvent.click(screen.getByText('Update Webhook'));
expect(onUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ minimumSeverity: 'warning' }));
});
it('calls onUpdate with updated data when saving an edited webhook', () => {
const webhook = makeWebhook({
id: 'wh-edit',
@@ -78,7 +78,9 @@ export function WebhookConfigList(props: WebhookConfigListProps) {
</span>
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
{getAlertDestinationSeverityLabel(
webhook.minimumSeverity === 'critical' ? 'critical' : 'all',
webhook.minimumSeverity === 'critical' || webhook.minimumSeverity === 'warning'
? webhook.minimumSeverity
: 'all',
)}
</span>
<For each={webhook.tagFilter ?? []}>
@@ -185,7 +185,9 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
mention: webhook.mention || '',
tagFilter: webhook.tagFilter ?? [],
tagFilterMode: webhook.tagFilterMode === 'any' ? 'any' : 'all',
minimumSeverity: webhook.minimumSeverity === 'critical' ? 'critical' : 'all',
minimumSeverity: webhook.minimumSeverity === 'critical' || webhook.minimumSeverity === 'warning'
? webhook.minimumSeverity
: 'all',
});
const headers = webhook.headers || {};
@@ -271,7 +273,9 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
mention: data.mention,
tagFilter: data.tagFilter ?? [],
tagFilterMode: data.tagFilterMode === 'any' ? 'any' : 'all',
minimumSeverity: data.minimumSeverity === 'critical' ? 'critical' : 'all',
minimumSeverity: data.minimumSeverity === 'critical' || data.minimumSeverity === 'warning'
? data.minimumSeverity
: 'all',
});
resetForm();
};