From f9be700f99a5e0472950a359367eba80d636cb18 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 10:20:30 +0100 Subject: [PATCH] Restore shared Ceph storage overrides in alerts thresholds Refs #1341 --- .../v6/internal/subsystems/alerts.md | 6 +- .../subsystems/frontend-primitives.md | 13 +- .../__tests__/alertOverridesModel.test.ts | 85 +++++++ .../__tests__/useAlertOverridesState.test.tsx | 56 +++++ .../features/alerts/alertOverridesModel.ts | 57 ++++- .../features/alerts/useAlertOverridesState.ts | 17 +- .../pages/__tests__/Alerts.helpers.test.ts | 67 +++++ .../tests/66-ceph-alert-thresholds.spec.ts | 234 ++++++++++++++++++ 8 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 tests/integration/tests/66-ceph-alert-thresholds.spec.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index fe4a828db..14bc5f9f6 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -549,7 +549,11 @@ ownership, config normalization, factory defaults, docker-gap validation, and save-payload serialization, `frontend-modern/src/features/alerts/alertOverridesModel.ts` for raw override -normalization plus resource-backed override projection, and +normalization plus resource-backed override projection. That override owner +must canonicalize legacy per-node shared-storage keys onto the current +cluster-scoped storage resource id before the thresholds surface rebuilds, +so old Ceph/shared-datastore overrides still surface on the live v6 editor +instead of disappearing after the feature-shell migration, and `frontend-modern/src/features/alerts/useAlertOverridesState.ts` for reactive override state, derived resource lists, and overview handoff, and `frontend-modern/src/features/alerts/alertDestinationsModel.ts` for email and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 903a1be9a..e709af2a3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -672,6 +672,13 @@ keeps pool-growth label/tone formatting inside the shared feature presentation layer. The storage page must keep reusing those shared owners instead of rebuilding storage-history timers or byte-delta formatting inside row components. +That same shared alerts feature boundary now also owns legacy shared-storage +override migration. `frontend-modern/src/features/alerts/alertOverridesModel.ts` +and `frontend-modern/src/features/alerts/useAlertOverridesState.ts` must +canonicalize per-node shared-storage override keys such as +`Main-pve1-ceph-pool` onto the current cluster-scoped storage resource id +before the thresholds table derives rows, so old Ceph override records survive +the v6 feature-shell path instead of silently disappearing from the live editor. The frontend already has several guardrail tests. The next step is to keep turning repeated local patterns into explicit shared primitives with hard usage @@ -2036,7 +2043,11 @@ ownership, `frontend-modern/src/features/alerts/alertsConfigurationModel.ts` for config normalization, factory defaults, docker-gap validation, and payload serialization, `frontend-modern/src/features/alerts/alertOverridesModel.ts` -for override normalization and resource-backed projection, and +for override normalization and resource-backed projection. That shared +feature-model boundary must also canonicalize legacy shared-storage override +keys onto the current storage resource id before thresholds rows are derived, +so migrated Ceph/shared-datastore overrides survive the feature-shell path +instead of dropping out of the live editor, and `frontend-modern/src/features/alerts/useAlertOverridesState.ts` for reactive override state and thresholds-facing resource selectors, and `frontend-modern/src/features/alerts/alertDestinationsModel.ts` for diff --git a/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts index bdaa1d319..feb929989 100644 --- a/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts @@ -31,6 +31,40 @@ describe('alertOverridesModel', () => { }); }); + it('normalizes legacy shared-storage override ids into canonical storage resource ids', () => { + const storage = makeResource({ + id: 'Main-cluster-ceph-pool', + name: 'ceph-pool', + type: 'storage', + platformId: 'Main', + proxmox: { + instance: 'Main', + node: 'cluster', + }, + storage: { + shared: true, + isCeph: true, + nodes: ['pve1', 'pve2'], + type: 'rbd', + }, + }); + + expect( + normalizeRawOverridesConfig( + { + 'Main-pve1-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + } as any, + }, + [storage], + ), + ).toEqual({ + 'Main-cluster-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + }, + }); + }); + it('projects guest overrides without requiring agent-backed resources', () => { const guest = makeResource({ id: 'cluster-a:node-2:100', @@ -172,4 +206,55 @@ describe('alertOverridesModel', () => { }), ]); }); + + it('projects shared-storage overrides through the canonical storage resource id', () => { + const storage = makeResource({ + id: 'Main-cluster-ceph-pool', + name: 'ceph-pool', + displayName: 'ceph-pool', + type: 'storage', + platformId: 'Main', + proxmox: { + instance: 'Main', + node: 'cluster', + }, + storage: { + shared: true, + isCeph: true, + nodes: ['pve1', 'pve2'], + type: 'rbd', + }, + platformData: { + node: 'cluster', + instance: 'Main', + }, + }); + + expect( + buildProjectedOverrides({ + rawConfig: { + 'Main-pve1-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + } as any, + }, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [storage], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + }), + ).toEqual([ + expect.objectContaining({ + id: 'Main-cluster-ceph-pool', + type: 'storage', + name: 'ceph-pool', + thresholds: { + usage: 92, + }, + }), + ]); + }); }); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx index 438d5d22a..c6f6a4079 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx @@ -123,6 +123,62 @@ describe('useAlertOverridesState', () => { expect(overviewOverrides()).toEqual([]); }); + it('canonicalizes shared-storage overrides for the live thresholds surface', async () => { + const [hasUnsavedChanges] = createSignal(false); + const [overviewOverrides, setOverviewOverrides] = createSignal([]); + const resources = [ + makeResource({ + id: 'Main-cluster-ceph-pool', + name: 'ceph-pool', + displayName: 'ceph-pool', + type: 'storage', + platformId: 'Main', + proxmox: { + instance: 'Main', + node: 'cluster', + }, + storage: { + shared: true, + isCeph: true, + nodes: ['pve1', 'pve2'], + type: 'rbd', + }, + platformData: { + node: 'cluster', + instance: 'Main', + }, + }), + ]; + + const { result } = renderHook(() => + useAlertOverridesState({ + allResources: () => resources, + byType: (resourceType) => resources.filter((resource) => resource.type === resourceType), + children: () => [], + hasUnsavedChanges, + setOverviewOverrides, + }), + ); + + result.replaceRawOverridesConfig({ + 'Main-pve1-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + } as any, + }); + + await waitFor(() => expect(result.overrides()).toHaveLength(1)); + + expect(Object.keys(result.rawOverridesConfig())).toEqual(['Main-cluster-ceph-pool']); + expect(result.overrides()[0]).toMatchObject({ + id: 'Main-cluster-ceph-pool', + type: 'storage', + thresholds: { + usage: 92, + }, + }); + expect(overviewOverrides()).toEqual(result.overrides()); + }); + it('exposes canonical container runtimes for TrueNAS-backed app workloads', async () => { const [hasUnsavedChanges] = createSignal(false); const [, setOverviewOverrides] = createSignal([]); diff --git a/frontend-modern/src/features/alerts/alertOverridesModel.ts b/frontend-modern/src/features/alerts/alertOverridesModel.ts index dc32fd60b..f70988407 100644 --- a/frontend-modern/src/features/alerts/alertOverridesModel.ts +++ b/frontend-modern/src/features/alerts/alertOverridesModel.ts @@ -38,11 +38,45 @@ const uniqueIds = (...values: unknown[]): string[] => { return ids; }; +const buildSharedStorageLegacyKeyMap = (storageResources: Resource[]): Map => { + const legacyToCanonical = new Map(); + + storageResources.forEach((resource) => { + const storageMeta = resource.storage; + const resourceName = asString(resource.name); + const proxmox = resource.proxmox; + const instance = asString(proxmox?.instance) || asString(resource.platformId); + const canonicalID = asString(resource.id); + + if (!storageMeta?.shared || !resourceName || !canonicalID) { + return; + } + + const clusterNodes = Array.isArray(storageMeta.nodes) ? storageMeta.nodes : []; + clusterNodes.forEach((node) => { + const normalizedNode = asString(node); + if (!normalizedNode) { + return; + } + + const prefix = + instance && !normalizedNode.toLowerCase().startsWith(`${instance.toLowerCase()}-`) + ? `${instance}-${normalizedNode}` + : normalizedNode; + legacyToCanonical.set(`${prefix}-${resourceName}`, canonicalID); + }); + }); + + return legacyToCanonical; +}; + export const normalizeRawOverridesConfig = ( rawOverrides: Record, + storageResources: Resource[] = [], ): Record => { const cleanedOverrides: Record = {}; const priorityByKey = new Map(); + const sharedStorageLegacyKeyMap = buildSharedStorageLegacyKeyMap(storageResources); for (const [key, value] of Object.entries(rawOverrides)) { const normalizedGuestKey = normalizeGuestOverrideKey(key); @@ -57,6 +91,10 @@ export const normalizeRawOverridesConfig = ( .replace(/^-|-$/g, '') || 'unknown'; normalizedKey = diskMatch[1] + normalized; } + const sharedStorageKey = sharedStorageLegacyKeyMap.get(normalizedKey); + if (sharedStorageKey) { + normalizedKey = sharedStorageKey; + } const priority = normalizedKey === key ? 1 : 0; const existingPriority = priorityByKey.get(normalizedKey); @@ -165,6 +203,7 @@ export const buildProjectedOverrides = ({ >(); const agentMap = new Map(); const guestMap = new Map(); + const storageMap = new Map(); const upsertProjectedOverride = (override: Override) => { const existingIndex = overrideIndexByID.get(override.id); @@ -215,6 +254,20 @@ export const buildProjectedOverrides = ({ }); }); + storageResources.forEach((storageResource) => { + const canonicalID = asString(storageResource.id); + if (canonicalID) { + storageMap.set(canonicalID, storageResource); + } + }); + + buildSharedStorageLegacyKeyMap(storageResources).forEach((canonicalID, legacyID) => { + const storageResource = storageMap.get(canonicalID); + if (storageResource) { + storageMap.set(legacyID, storageResource); + } + }); + [...vmResources, ...containerResources].forEach((guest) => { guestOverrideIdCandidates(guest).forEach((candidate) => { guestMap.set(candidate, guest); @@ -361,11 +414,11 @@ export const buildProjectedOverrides = ({ return; } - const storage = storageResources.find((resource) => resource.id === key); + const storage = storageMap.get(key); if (storage) { const coords = storageCoords(storage); upsertProjectedOverride({ - id: key, + id: storage.id, name: getAlertResourceDisplayLabel(storage), type: 'storage', resourceType: 'Storage', diff --git a/frontend-modern/src/features/alerts/useAlertOverridesState.ts b/frontend-modern/src/features/alerts/useAlertOverridesState.ts index 6ad77d787..444171349 100644 --- a/frontend-modern/src/features/alerts/useAlertOverridesState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverridesState.ts @@ -81,6 +81,15 @@ export function useAlertOverridesState(props: AlertOverridesStateProps) { } const rawConfig = rawOverridesConfig(); + const storageResources = props + .allResources() + .filter((resource) => resource.type === 'storage' || resource.type === 'datastore'); + const normalizedRawConfig = normalizeRawOverridesConfig(rawConfig, storageResources); + if (JSON.stringify(normalizedRawConfig) !== JSON.stringify(rawConfig)) { + setRawOverridesConfig(normalizedRawConfig); + return; + } + if (Object.keys(rawConfig).length === 0) { if (overrides().length > 0) { setOverrides([]); @@ -94,9 +103,6 @@ export function useAlertOverridesState(props: AlertOverridesStateProps) { ...props.byType('system-container'), ...props.byType('oci-container'), ]; - const storageResources = props - .allResources() - .filter((resource) => resource.type === 'storage' || resource.type === 'datastore'); const agentResourceList = agentResources(); const overridesList = buildProjectedOverrides({ rawConfig, @@ -137,7 +143,10 @@ export function useAlertOverridesState(props: AlertOverridesStateProps) { }); const replaceRawOverridesConfig = (value: Record) => { - setRawOverridesConfig(normalizeRawOverridesConfig(value)); + const storageResources = props + .allResources() + .filter((resource) => resource.type === 'storage' || resource.type === 'datastore'); + setRawOverridesConfig(normalizeRawOverridesConfig(value, storageResources)); }; return { diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 61c410bea..add93ec80 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -88,6 +88,10 @@ import { normalizeMetricDelayMap, unifiedTypeToAlertDisplayType, } from '@/features/alerts/helpers'; +import { + buildProjectedOverrides, + normalizeRawOverridesConfig, +} from '@/features/alerts/alertOverridesModel'; import { getAlertIncidentAcknowledgedBadgeClass, getAlertIncidentEventFilterActionButtonClass, @@ -194,6 +198,69 @@ describe('alert resource display labels', () => { }); }); +describe('shared storage override migration', () => { + it('keeps legacy Ceph override ids visible on the v6 thresholds surface', () => { + const storage = { + id: 'Main-cluster-ceph-pool', + name: 'ceph-pool', + displayName: 'ceph-pool', + type: 'storage', + platformId: 'Main', + proxmox: { + instance: 'Main', + node: 'cluster', + }, + storage: { + shared: true, + isCeph: true, + nodes: ['pve1', 'pve2'], + type: 'rbd', + }, + platformData: { + instance: 'Main', + node: 'cluster', + }, + } as Resource; + + const rawConfig = normalizeRawOverridesConfig( + { + 'Main-pve1-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + } as RawOverrideConfig, + }, + [storage], + ); + + expect(rawConfig).toEqual({ + 'Main-cluster-ceph-pool': { + usage: { trigger: 92, clear: 82 }, + }, + }); + + expect( + buildProjectedOverrides({ + rawConfig, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [storage], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + }), + ).toEqual([ + expect.objectContaining({ + id: 'Main-cluster-ceph-pool', + type: 'storage', + thresholds: { + usage: 92, + }, + }), + ]); + }); +}); + describe('tab path helpers', () => { it('maps tab to path', () => { expect(pathForTab('overview')).toBe('/alerts/overview'); diff --git a/tests/integration/tests/66-ceph-alert-thresholds.spec.ts b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts new file mode 100644 index 000000000..a5b31bc0b --- /dev/null +++ b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as base, expect } from '@playwright/test'; + +import { createAuthenticatedStorageState } from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => { + await use(authStorageStatePath); + }, + authStorageStatePath: [async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'playwright-auth', + `ceph-alert-thresholds-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, { scope: 'worker' }], +}); + +test.use({ serviceWorkers: 'block' }); + +test.describe('Ceph alert thresholds', () => { + test.setTimeout(180_000); + + test('keeps shared Ceph storage visible and editable on the live thresholds surface', async ({ + page, + }) => { + await page.addInitScript(() => { + class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readonly url: string; + readyState = FakeWebSocket.CLOSED; + onopen: ((event: Event) => void) | null = null; + onclose: + | ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) + | null = null; + onerror: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + + constructor(url: string) { + this.url = url; + queueMicrotask(() => { + this.onclose?.({ code: 1006, reason: 'e2e websocket disabled', wasClean: false }); + }); + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + send() {} + + addEventListener() {} + + removeEventListener() {} + } + + // @ts-expect-error Playwright init script runs in the browser context. + window.WebSocket = FakeWebSocket; + }); + + await page.context().route('**/api/resources**', async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.pathname !== '/api/resources') { + await route.continue(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + id: 'Main-pve1', + type: 'agent', + name: 'pve1', + displayName: 'pve1', + platformId: 'Main', + platformType: 'proxmox-pve', + sourceType: 'api', + sources: ['proxmox'], + status: 'online', + canonicalIdentity: { + displayName: 'pve1', + hostname: 'pve1', + platformId: 'Main', + }, + proxmox: { + node: 'pve1', + instance: 'Main', + }, + platformData: { + node: 'pve1', + instance: 'Main', + clusterName: 'Main', + isClusterMember: true, + sources: ['proxmox'], + }, + }, + { + id: 'Main-cluster-ceph-pool', + type: 'storage', + name: 'ceph-pool', + displayName: 'ceph-pool', + platformId: 'Main', + platformType: 'proxmox-pve', + sourceType: 'api', + sources: ['proxmox'], + status: 'available', + canonicalIdentity: { + displayName: 'ceph-pool', + platformId: 'Main-cluster-ceph-pool', + }, + storage: { + type: 'rbd', + shared: true, + isCeph: true, + nodes: ['pve1', 'pve2'], + }, + proxmox: { + node: 'cluster', + instance: 'Main', + }, + platformData: { + node: 'cluster', + instance: 'Main', + sources: ['proxmox'], + }, + }, + ], + meta: { + page: 1, + limit: 200, + total: 2, + totalPages: 1, + }, + }), + }); + }); + + await page.context().route('**/api/alerts/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + enabled: true, + activationState: 'active', + storageDefault: { + trigger: 85, + clear: 80, + }, + overrides: { + 'Main-pve1-ceph-pool': { + usage: { + trigger: 92, + clear: 82, + }, + }, + }, + }), + }); + }); + + await page.context().route('**/api/alerts/active', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); + + await page.context().route('**/api/notifications/email', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + enabled: false, + provider: '', + server: '', + port: 587, + username: '', + password: '', + from: '', + to: [], + tls: false, + startTLS: false, + }), + }); + }); + + await page.context().route('**/api/notifications/apprise', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + enabled: false, + }), + }); + }); + + await page.goto('/alerts/thresholds/infrastructure', { + waitUntil: 'domcontentloaded', + }); + + await expect(page).toHaveURL(/\/alerts\/thresholds\/infrastructure/); + await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Storage Devices' })).toBeVisible(); + await expect(page.getByText('ceph-pool', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Revert to defaults for ceph-pool' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Edit thresholds for ceph-pool' })).toBeVisible(); + }); +});