Restore shared Ceph storage overrides in alerts thresholds

Refs #1341
This commit is contained in:
rcourtman
2026-04-22 10:20:30 +01:00
parent 74df03c78c
commit f9be700f99
8 changed files with 527 additions and 8 deletions
@@ -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
@@ -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
@@ -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,
},
}),
]);
});
});
@@ -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([]);
@@ -38,11 +38,45 @@ const uniqueIds = (...values: unknown[]): string[] => {
return ids;
};
const buildSharedStorageLegacyKeyMap = (storageResources: Resource[]): Map<string, string> => {
const legacyToCanonical = new Map<string, string>();
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<string, RawOverrideConfig>,
storageResources: Resource[] = [],
): Record<string, RawOverrideConfig> => {
const cleanedOverrides: Record<string, RawOverrideConfig> = {};
const priorityByKey = new Map<string, number>();
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<string, Resource>();
const guestMap = new Map<string, Resource>();
const storageMap = new Map<string, Resource>();
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',
@@ -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<string, RawOverrideConfig>) => {
setRawOverridesConfig(normalizeRawOverridesConfig(value));
const storageResources = props
.allResources()
.filter((resource) => resource.type === 'storage' || resource.type === 'datastore');
setRawOverridesConfig(normalizeRawOverridesConfig(value, storageResources));
};
return {
@@ -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');
@@ -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();
});
});