diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 53ed99cdf3..a23f7815de 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -2936,3 +2936,15 @@ 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.
+
+Formatting-only follow-up retains this warning-level contract. The production-component
+browser matrix was rerun after formatting at desktop and narrow widths, including
+all/critical/warning save/reload, cancel, and warning webhook creation.
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 5f27e02b1f..5f9fb209cc 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -10828,3 +10828,15 @@ 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.
+
+Formatting-only follow-up retains this warning-level contract. The production-component
+browser matrix was rerun after formatting at desktop and narrow widths, including
+all/critical/warning save/reload, cancel, and warning webhook creation.
diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md
index 01c1075211..0aaf7680ac 100644
--- a/docs/release-control/v6/internal/subsystems/notifications.md
+++ b/docs/release-control/v6/internal/subsystems/notifications.md
@@ -910,3 +910,15 @@ 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.
+
+Formatting-only follow-up retains this warning-level contract. The production-component
+browser matrix was rerun after formatting at desktop and narrow widths, including
+all/critical/warning save/reload, cancel, and warning webhook creation.
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 8bd43ae87e..6bcf4a39e7 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -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:45:40.182293Z",
"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": "ed4d7dd4946ed2295dff919134f394d0139d270bb446d069f4ddc8a9784b1667",
+ "frontend-modern/src/components/Alerts/WebhookConfigList.tsx": "0830ab5c9f7e0173dbdf3e92ed0e0c9cbf0a34942edb58001a3220d43b39d6c3",
+ "frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "b641c911baeda41adfbf82621ede005db2bdc032f25786d4533c422b59b64047"
},
"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."
}
diff --git a/frontend-modern/src/api/__tests__/notifications.test.ts b/frontend-modern/src/api/__tests__/notifications.test.ts
index a14d671d27..02bc33f61c 100644
--- a/frontend-modern/src/api/__tests__/notifications.test.ts
+++ b/frontend-modern/src/api/__tests__/notifications.test.ts
@@ -424,3 +424,22 @@ 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);
+ },
+ );
+});
diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts
index 9d8cab763f..87a63eabfd 100644
--- a/frontend-modern/src/api/notifications.ts
+++ b/frontend-modern/src/api/notifications.ts
@@ -437,7 +437,10 @@ 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 +470,10 @@ 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`, {
diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
index 5498be0756..ec1088c947 100644
--- a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
+++ b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
@@ -711,6 +711,24 @@ 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(() => (
+
+ ));
+ 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',
diff --git a/frontend-modern/src/components/Alerts/WebhookConfigList.tsx b/frontend-modern/src/components/Alerts/WebhookConfigList.tsx
index 547275e72d..5347c868be 100644
--- a/frontend-modern/src/components/Alerts/WebhookConfigList.tsx
+++ b/frontend-modern/src/components/Alerts/WebhookConfigList.tsx
@@ -78,7 +78,9 @@ export function WebhookConfigList(props: WebhookConfigListProps) {
{getAlertDestinationSeverityLabel(
- webhook.minimumSeverity === 'critical' ? 'critical' : 'all',
+ webhook.minimumSeverity === 'critical' || webhook.minimumSeverity === 'warning'
+ ? webhook.minimumSeverity
+ : 'all',
)}
diff --git a/frontend-modern/src/components/Alerts/useWebhookConfigState.ts b/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
index e7086e25b9..71aad94d27 100644
--- a/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
+++ b/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
@@ -185,7 +185,10 @@ 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 +274,10 @@ 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();
};