refactor(alerts): canonicalize thresholds surface routing

This commit is contained in:
rcourtman 2026-03-30 02:38:32 +01:00
parent 0f736ef5eb
commit b2344cdbbd
16 changed files with 390 additions and 94 deletions

View file

@ -234,7 +234,14 @@ tab render owners live in
threshold row grouping, override-ID compatibility, resource normalization,
thresholds-table controller logic, or per-tab runtime should land in those
feature hooks and tab owners rather than being rebuilt inside the shell.
Within the Proxmox tab, render-heavy ownership now further routes through
The shell-owned thresholds sub-routes are now the neutral user-facing paths
`/alerts/thresholds/infrastructure`, `/alerts/thresholds/systems`,
`/alerts/thresholds/mail-gateway`, and `/alerts/thresholds/containers`.
Legacy `/alerts/thresholds/proxmox` and `/alerts/thresholds/agents` links
must redirect to the neutral infrastructure and systems routes so API-backed
platforms like TrueNAS do not remain stranded behind provider-specific deep
links.
Within the infrastructure tab, render-heavy ownership now further routes through
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx`,
@ -244,7 +251,7 @@ Within the Proxmox tab, render-heavy ownership now further routes through
and `frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx`
with the shared section contract in
`frontend-modern/src/features/alerts/thresholds/thresholdsTableSectionProps.ts`.
Future Proxmox thresholds presentation work should extend those section owners
Future infrastructure-thresholds presentation work should extend those section owners
instead of expanding `frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx`
back into a mixed render surface.
The Docker tab now follows that same section-owner shape through
@ -255,10 +262,10 @@ and `frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSectio
Future Docker thresholds presentation work should extend those section owners
instead of expanding `frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`
back into a mixed render surface.
The agents tab now follows that same shell-versus-section pattern through
The systems tab now follows that same shell-versus-section pattern through
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx`
and `frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx`.
Future agent thresholds presentation work should extend those section owners
Future systems-thresholds presentation work should extend those section owners
instead of expanding `frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`
back into a mixed render surface.
The alert resource thresholds editor now follows the same shape: shared metric

View file

@ -1230,7 +1230,15 @@ and the tab render owners live in
`frontend-modern/src/components/Alerts/ThresholdsTablePMGTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`, and
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`.
The Proxmox tab is itself now a shell that composes
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
owns the neutral thresholds sub-route contract:
`/alerts/thresholds/infrastructure`, `/alerts/thresholds/systems`,
`/alerts/thresholds/mail-gateway`, and `/alerts/thresholds/containers`.
Legacy `/alerts/thresholds/proxmox` and `/alerts/thresholds/agents` links
must redirect to the neutral infrastructure and systems routes so API-backed
platforms such as TrueNAS stay on canonical page language rather than
provider-specific aliases.
The infrastructure tab is itself now a shell that composes
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx`,
@ -1240,7 +1248,7 @@ The Proxmox tab is itself now a shell that composes
and `frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx`
using the shared contract in
`frontend-modern/src/features/alerts/thresholds/thresholdsTableSectionProps.ts`.
Future Proxmox thresholds presentation changes should extend those section
Future infrastructure-thresholds presentation changes should extend those section
surfaces rather than restoring mixed JSX ownership to
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx`.
The Docker tab now follows that same composition pattern through
@ -1251,10 +1259,10 @@ and `frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSectio
Future Docker thresholds presentation changes should extend those section
surfaces rather than restoring mixed JSX ownership to
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`.
The agents tab now follows that same composition pattern through
The systems tab now follows that same composition pattern through
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx`
and `frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx`.
Future agent thresholds presentation changes should extend those section
Future systems-thresholds presentation changes should extend those section
surfaces rather than restoring mixed JSX ownership to
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`.
The thresholds tab adapter contract now lives in

View file

@ -87,12 +87,11 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
<nav class="-mb-px flex gap-4 sm:gap-6" aria-label="Tabs">
<button
type="button"
onClick={() => state.handleTabClick('proxmox')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'proxmox' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
onClick={() => state.handleTabClick('infrastructure')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'infrastructure' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Server class="w-4 h-4" />
<span class="hidden sm:inline">Proxmox / PBS</span>
<span class="sm:hidden">Proxmox</span>
<span>Infrastructure</span>
</button>
<button
type="button"
@ -105,11 +104,11 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</button>
<button
type="button"
onClick={() => state.handleTabClick('agents')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'agents' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
onClick={() => state.handleTabClick('systems')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'systems' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Users class="w-4 h-4" />
<span>Agents</span>
<span>Systems</span>
</button>
<button
type="button"
@ -122,7 +121,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</nav>
</div>
<Show when={state.activeTab() === 'proxmox'}>
<Show when={state.activeTab() === 'infrastructure'}>
<div class="flex justify-end gap-2">
<button
type="button"
@ -143,7 +142,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</Show>
<div class="space-y-6">
<Show when={state.activeTab() === 'proxmox'}>
<Show when={state.activeTab() === 'infrastructure'}>
<ThresholdsTableProxmoxTab state={state} tableProps={props} />
</Show>
@ -151,7 +150,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
<ThresholdsTablePMGTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'agents'}>
<Show when={state.activeTab() === 'systems'}>
<ThresholdsTableAgentsTab state={state} tableProps={props} />
</Show>

View file

@ -258,14 +258,31 @@ describe('ThresholdsTable basics', () => {
});
describe('ThresholdsTable navigation and redirection', () => {
it('redirects from base path to proxmox', () => {
it('redirects from base path to infrastructure', () => {
setPathname('/alerts/thresholds');
render(() => <ThresholdsTable {...(baseProps() as any)} />);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/proxmox', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure', {
replace: true,
});
});
it('loads agents tab from canonical route', async () => {
it('redirects legacy thresholds sub-routes onto canonical infrastructure and systems paths', () => {
setPathname('/alerts/thresholds/proxmox');
render(() => <ThresholdsTable {...(baseProps() as any)} />);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure', {
replace: true,
});
cleanup();
mockNavigate.mockReset();
setPathname('/alerts/thresholds/agents');
render(() => <ThresholdsTable {...(baseProps() as any)} />);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/systems', { replace: true });
});
it('loads systems tab from canonical route', async () => {
setPathname('/alerts/thresholds/systems');
const host: Agent = {
id: 'legacy-h1',
hostname: 'legacy-host',
@ -278,16 +295,24 @@ describe('ThresholdsTable navigation and redirection', () => {
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
await waitFor(() => {
expect(screen.getByTestId('resource-table-Agents')).toBeInTheDocument();
expect(screen.getByTestId('resource-table-Systems')).toBeInTheDocument();
});
});
it('navigates to correct route when tabs are clicked', () => {
render(() => <ThresholdsTable {...(baseProps() as any)} />);
const hostsTab = screen.getAllByRole('button').find((el) => el.textContent?.includes('Agents'));
if (hostsTab) fireEvent.click(hostsTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/agents');
const infrastructureTab = screen
.getAllByRole('button')
.find((el) => el.textContent?.includes('Infrastructure'));
if (infrastructureTab) fireEvent.click(infrastructureTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure');
const systemsTab = screen
.getAllByRole('button')
.find((el) => el.textContent?.includes('Systems'));
if (systemsTab) fireEvent.click(systemsTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/systems');
const mailTab = screen
.getAllByRole('button')
@ -298,8 +323,8 @@ describe('ThresholdsTable navigation and redirection', () => {
});
describe('ThresholdsTable Resource Rendering', () => {
it('renders agents correctly', async () => {
setPathname('/alerts/thresholds/agents');
it('renders systems correctly', async () => {
setPathname('/alerts/thresholds/systems');
const host: Agent = {
id: 'h1',
hostname: 'host1',
@ -312,15 +337,15 @@ describe('ThresholdsTable Resource Rendering', () => {
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
await waitFor(() => {
expect(screen.getByTestId('resource-table-Agents')).toBeInTheDocument();
expect(screen.getByTestId('resource-table-Systems')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-count-Agents')).toHaveTextContent('1');
expect(screen.getByTestId('resource-count-Systems')).toHaveTextContent('1');
expect(screen.getByTestId('resource-name-h1')).toHaveTextContent('Host 1');
});
it('renders governed agents with the policy-aware display label', async () => {
setPathname('/alerts/thresholds/agents');
it('renders governed systems with the policy-aware display label', async () => {
setPathname('/alerts/thresholds/systems');
const host = {
id: 'h2',
hostname: 'secret-host',
@ -338,15 +363,43 @@ describe('ThresholdsTable Resource Rendering', () => {
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
await waitFor(() => {
expect(screen.getByTestId('resource-table-Agents')).toBeInTheDocument();
expect(screen.getByTestId('resource-table-Systems')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-name-h2')).toHaveTextContent('redacted by policy');
expect(screen.getByTestId('resource-name-h2')).not.toHaveTextContent('secret-host');
});
it('renders proxmox nodes and guests correctly', async () => {
setPathname('/alerts/thresholds/proxmox');
it('renders TrueNAS appliances on the canonical systems tab with their disk surface', async () => {
setPathname('/alerts/thresholds/systems');
const truenasSystem = {
id: 'truenas-resource',
type: 'truenas',
name: 'truenas-main',
displayName: 'TrueNAS Main',
status: 'online',
lastSeen: 123,
platformData: {
agent: {
agentId: 'truenas-main',
disks: [{ mountpoint: '/mnt/tank', type: 'zfs', used: 50, total: 100 }],
},
},
} as any;
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[truenasSystem]} />);
await waitFor(() => {
expect(screen.getByTestId('resource-name-truenas-main')).toHaveTextContent('TrueNAS Main');
});
expect(
screen.getByTestId('resource-row-agent:truenas-main/disk:mnt-tank'),
).toBeInTheDocument();
});
it('renders infrastructure hosts and guests correctly', async () => {
setPathname('/alerts/thresholds/infrastructure');
const node = {
id: 'node1',
name: 'pve1',
@ -366,7 +419,7 @@ describe('ThresholdsTable Resource Rendering', () => {
));
await waitFor(() => {
expect(screen.getByTestId('section-Proxmox Nodes')).toBeInTheDocument();
expect(screen.getByTestId('section-Virtualization Hosts')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-name-node1')).toHaveTextContent('PVE');
@ -376,7 +429,7 @@ describe('ThresholdsTable Resource Rendering', () => {
});
it('renders governed guests with the policy-aware display label', async () => {
setPathname('/alerts/thresholds/proxmox');
setPathname('/alerts/thresholds/infrastructure');
const guest = {
id: 'guest2',
name: 'secret-vm-2',
@ -402,7 +455,7 @@ describe('ThresholdsTable Resource Rendering', () => {
});
it('renders governed guest groups with the policy-aware node header label', async () => {
setPathname('/alerts/thresholds/proxmox');
setPathname('/alerts/thresholds/infrastructure');
const node = {
id: 'node-governed',
name: 'secret-node',
@ -438,7 +491,7 @@ describe('ThresholdsTable Resource Rendering', () => {
});
it('renders governed storage with the policy-aware display label', async () => {
setPathname('/alerts/thresholds/proxmox');
setPathname('/alerts/thresholds/infrastructure');
const storage = {
id: 'storage1',
name: 'secret-datastore',
@ -514,7 +567,7 @@ describe('ThresholdsTable Resource Rendering', () => {
});
it('renders governed agent disk node labels with the policy-aware display label', async () => {
setPathname('/alerts/thresholds/agents');
setPathname('/alerts/thresholds/systems');
const host = {
id: 'agent-governed',
type: 'agent',
@ -552,7 +605,7 @@ describe('ThresholdsTable Resource Rendering', () => {
describe('ThresholdsTable Metric Formatting', () => {
it('formats metrics correctly', async () => {
setPathname('/alerts/thresholds/agents');
setPathname('/alerts/thresholds/systems');
const host: Agent = {
id: 'h1',
hostname: 'host1',
@ -583,7 +636,7 @@ describe('ThresholdsTable Metric Formatting', () => {
describe('ThresholdsTable V6 ID compatibility', () => {
it('matches agent overrides keyed by actionable agent ID', async () => {
setPathname('/alerts/thresholds/agents');
setPathname('/alerts/thresholds/systems');
const host = {
id: 'resource:host:abc123',
type: 'agent',
@ -749,7 +802,7 @@ describe('ThresholdsTable V6 ID compatibility', () => {
});
it('removes PBS offline alerts using legacy compatibility IDs when disabled', async () => {
setPathname('/alerts/thresholds/proxmox');
setPathname('/alerts/thresholds/infrastructure');
const removeAlerts = vi.fn();
const pbs = {
id: 'pbs-main',

View file

@ -4,7 +4,7 @@ import { cleanup, render } from '@solidjs/testing-library';
import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types';
import { useThresholdsTableState } from '../useThresholdsTableState';
let mockPathname = '/alerts/thresholds/agents';
let mockPathname = '/alerts/thresholds/systems';
const navigateSpy = vi.fn();
vi.mock('@solidjs/router', () => ({
@ -161,7 +161,7 @@ const buildProps = (): ThresholdsTableProps =>
}) as unknown as ThresholdsTableProps;
beforeEach(() => {
mockPathname = '/alerts/thresholds/agents';
mockPathname = '/alerts/thresholds/systems';
navigateSpy.mockReset();
localStorage.clear();
});
@ -182,7 +182,7 @@ describe('useThresholdsTableState', () => {
render(() => <Harness />);
expect(captured).toBeDefined();
expect(captured!.activeTab()).toBe('agents');
expect(captured!.activeTab()).toBe('systems');
captured!.dismissHelpBanner();
expect(captured!.helpBannerDismissed()).toBe(true);

View file

@ -63,7 +63,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
rawName: originalDisplayName,
host: normalizedHost,
type: 'agent' as const,
resourceType: 'Agent',
resourceType: 'Virtualization Host',
status: node.status,
uptime: node.uptime,
cpu: (node.cpu?.current ?? 0) / 100,
@ -112,7 +112,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
displayName,
rawName: agentResource.identity?.hostname ?? agentResource.name,
type: 'agent' as const,
resourceType: 'Agent',
resourceType: 'System',
node: displayName,
instance:
readString(agentData?.platform) ||
@ -140,7 +140,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
displayName: name,
rawName: name,
type: 'agent' as const,
resourceType: 'Agent',
resourceType: 'System',
node: '',
instance: '',
status: 'unknown',
@ -208,7 +208,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
displayName: diskLabel,
rawName: disk.device || diskLabel,
type: 'agentDisk' as const,
resourceType: 'Agent Disk',
resourceType: 'System Disk',
host: agentIdForActions,
node: agentDisplayName,
instance: disk.type || '',
@ -232,7 +232,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
displayName: name,
rawName: name,
type: 'agentDisk' as const,
resourceType: 'Agent Disk',
resourceType: 'System Disk',
host: '',
node: 'Unknown Agent',
instance: '',

View file

@ -98,7 +98,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const [bulkEditIds, setBulkEditIds] = createSignal<string[]>([]);
const [bulkEditColumns, setBulkEditColumns] = createSignal<string[]>([]);
const [isBulkEditDialogOpen, setIsBulkEditDialogOpen] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<ThresholdsActiveTab>('proxmox');
const [activeTab, setActiveTab] = createSignal<ThresholdsActiveTab>('infrastructure');
const [dockerIgnoredInput, setDockerIgnoredInput] = createSignal(
props.dockerIgnoredPrefixes().join('\n'),
);
@ -131,9 +131,9 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const getActiveTabFromRoute = (): ThresholdsActiveTab => {
const path = location.pathname;
if (path.includes('/thresholds/containers')) return 'docker';
if (path.includes('/thresholds/agents')) return 'agents';
if (path.includes('/thresholds/systems') || path.includes('/thresholds/agents')) return 'systems';
if (path.includes('/thresholds/mail-gateway')) return 'pmg';
return 'proxmox';
return 'infrastructure';
};
createEffect(() => {
@ -145,16 +145,26 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
createEffect(() => {
if (location.pathname === '/alerts/thresholds') {
navigate('/alerts/thresholds/proxmox', { replace: true });
navigate('/alerts/thresholds/infrastructure', { replace: true });
return;
}
if (location.pathname === '/alerts/thresholds/proxmox') {
navigate('/alerts/thresholds/infrastructure', { replace: true });
return;
}
if (location.pathname === '/alerts/thresholds/agents') {
navigate('/alerts/thresholds/systems', { replace: true });
}
});
const handleTabClick = (tab: ThresholdsActiveTab) => {
const tabRoutes: Record<ThresholdsActiveTab, string> = {
agents: '/alerts/thresholds/agents',
infrastructure: '/alerts/thresholds/infrastructure',
docker: '/alerts/thresholds/containers',
pmg: '/alerts/thresholds/mail-gateway',
proxmox: '/alerts/thresholds/proxmox',
systems: '/alerts/thresholds/systems',
};
navigate(tabRoutes[tab]);
};
@ -284,9 +294,9 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const items: ThresholdsSummaryItem[] = [
{
key: 'nodes',
label: 'Nodes',
label: 'Virtualization Hosts',
overrides: countOverrides(nodesWithOverrides()),
tab: 'proxmox',
tab: 'infrastructure',
total: props.nodes?.length ?? 0,
},
{
@ -298,44 +308,44 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
},
{
key: 'agents',
label: 'Agents',
label: 'Systems',
overrides: countOverrides(agentsWithOverrides()),
tab: 'agents',
tab: 'systems',
total: props.agents?.length ?? 0,
},
{
key: 'agentDisks',
label: 'Agent Disks',
label: 'System Disks',
overrides: countOverrides(agentDisksWithOverrides()),
tab: 'agents',
tab: 'systems',
total: agentDisksWithOverrides().length,
},
{
key: 'storage',
label: 'Storage',
overrides: countOverrides(storageWithOverrides()),
tab: 'proxmox',
tab: 'infrastructure',
total: props.storage?.length ?? 0,
},
{
key: 'backups',
label: 'Recovery',
overrides: backupOverridesCount(),
tab: 'proxmox',
tab: 'infrastructure',
total: 1,
},
{
key: 'snapshots',
label: 'Snapshot Age',
overrides: snapshotOverridesCount(),
tab: 'proxmox',
tab: 'infrastructure',
total: 1,
},
{
key: 'pbs',
label: 'PBS Servers',
overrides: countOverrides(pbsServersWithOverrides()),
tab: 'proxmox',
tab: 'infrastructure',
total: props.pbsInstances?.length ?? 0,
},
{
@ -356,7 +366,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
key: 'guests',
label: 'VMs & Containers',
overrides: countOverrides(guestsFlat()),
tab: 'proxmox',
tab: 'infrastructure',
total: props.allGuests?.()?.length ?? 0,
},
];

View file

@ -2,7 +2,7 @@ import type { ResourcePolicy } from '@/types/resource';
import type { BackupAlertConfig, SnapshotAlertConfig } from '@/types/alerts';
import type { AlertResourceThresholdMap } from '@/components/Alerts/alertResourceTableModel';
export type ThresholdsActiveTab = 'proxmox' | 'pmg' | 'agents' | 'docker';
export type ThresholdsActiveTab = 'infrastructure' | 'pmg' | 'systems' | 'docker';
export interface Resource {
id: string;

View file

@ -134,7 +134,7 @@ export function Alerts() {
const expectedPath = pathForTab(tab);
// Allow sub-paths for thresholds tab (e.g., /alerts/thresholds/proxmox)
// Allow sub-paths for thresholds tab (e.g., /alerts/thresholds/infrastructure)
const isThresholdsSubPath =
tab === 'thresholds' && currentPath.startsWith('/alerts/thresholds/');

View file

@ -203,6 +203,8 @@ describe('tab path helpers', () => {
it('resolves tab from path', () => {
expect(tabFromPath('/alerts')).toBe('overview');
expect(tabFromPath('/alerts/thresholds')).toBe('thresholds');
expect(tabFromPath('/alerts/thresholds/infrastructure')).toBe('thresholds');
expect(tabFromPath('/alerts/thresholds/systems')).toBe('thresholds');
expect(tabFromPath('/alerts/thresholds/proxmox')).toBe('thresholds');
expect(tabFromPath('/alerts/custom-rules')).toBe('thresholds');
expect(tabFromPath('/foo/bar')).toBe('overview');

View file

@ -101,7 +101,7 @@ describe('alertOverviewPresentation', () => {
thresholds: {
title: 'Alert Thresholds',
description:
'Tune resource thresholds and override rules for nodes, guests, and containers.',
'Tune threshold and override rules for infrastructure, systems, storage, and containers.',
},
destinations: {
title: 'Notification Destinations',

View file

@ -59,7 +59,9 @@ describe('alertThresholdsPresentation', () => {
it('exports canonical thresholds empty-state copy', () => {
expect(PBS_THRESHOLDS_EMPTY_STATE).toBe('No PBS servers configured.');
expect(GUEST_THRESHOLDS_EMPTY_STATE).toBe('No VMs or containers found.');
expect(NODE_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No nodes match the current filters.');
expect(NODE_THRESHOLDS_FILTER_EMPTY_STATE).toBe(
'No virtualization hosts match the current filters.',
);
expect(PBS_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No PBS servers match the current filters.');
expect(GUEST_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No VMs or containers match the current filters.');
expect(GUEST_FILTERING_EMPTY_STATE).toBe('Configure guest filtering rules.');
@ -69,9 +71,9 @@ describe('alertThresholdsPresentation', () => {
expect(STORAGE_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No storage devices match the current filters.');
expect(PMG_THRESHOLDS_EMPTY_STATE).toContain('No mail gateways configured yet.');
expect(PMG_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No mail gateways match the current filters.');
expect(AGENT_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No agents match the current filters.');
expect(AGENT_DISKS_EMPTY_STATE).toContain('Agents with mounted filesystems will appear here.');
expect(AGENT_DISKS_FILTER_EMPTY_STATE).toBe('No agent disks match the current filters.');
expect(AGENT_THRESHOLDS_FILTER_EMPTY_STATE).toBe('No systems match the current filters.');
expect(AGENT_DISKS_EMPTY_STATE).toContain('Systems with mounted filesystems will appear here.');
expect(AGENT_DISKS_FILTER_EMPTY_STATE).toBe('No system disks match the current filters.');
expect(CONTAINER_RUNTIMES_FILTER_EMPTY_STATE).toBe('No container runtimes match the current filters.');
expect(CONTAINERS_FILTER_EMPTY_STATE).toBe('No containers match the current filters.');
});
@ -167,7 +169,7 @@ describe('alertThresholdsPresentation', () => {
});
it('exports canonical thresholds section titles', () => {
expect(ALERT_THRESHOLDS_SECTION_TITLE_NODES).toBe('Proxmox Nodes');
expect(ALERT_THRESHOLDS_SECTION_TITLE_NODES).toBe('Virtualization Hosts');
expect(ALERT_THRESHOLDS_SECTION_TITLE_PBS).toBe('PBS Servers');
expect(ALERT_THRESHOLDS_SECTION_TITLE_GUESTS).toBe('VMs & Containers');
expect(ALERT_THRESHOLDS_SECTION_TITLE_GUEST_FILTERING).toBe('Guest Filtering');
@ -175,12 +177,12 @@ describe('alertThresholdsPresentation', () => {
expect(ALERT_THRESHOLDS_SECTION_TITLE_SNAPSHOTS).toBe('Snapshot Age');
expect(ALERT_THRESHOLDS_SECTION_TITLE_STORAGE).toBe('Storage Devices');
expect(ALERT_THRESHOLDS_SECTION_TITLE_PMG).toBe('Mail Gateway Thresholds');
expect(ALERT_THRESHOLDS_SECTION_TITLE_AGENTS).toBe('Agents');
expect(ALERT_THRESHOLDS_SECTION_TITLE_AGENT_DISKS).toBe('Agent Disks');
expect(ALERT_THRESHOLDS_SECTION_TITLE_AGENTS).toBe('Systems');
expect(ALERT_THRESHOLDS_SECTION_TITLE_AGENT_DISKS).toBe('System Disks');
expect(ALERT_THRESHOLDS_SECTION_TITLE_DOCKER_HOSTS).toBe('Container Runtimes');
expect(ALERT_THRESHOLDS_SECTION_TITLE_DOCKER_CONTAINERS).toBe('Containers');
expect(getAlertThresholdsSectionTitles()).toEqual({
nodes: 'Proxmox Nodes',
nodes: 'Virtualization Hosts',
pbs: 'PBS Servers',
guests: 'VMs & Containers',
guestFiltering: 'Guest Filtering',
@ -188,8 +190,8 @@ describe('alertThresholdsPresentation', () => {
snapshots: 'Snapshot Age',
storage: 'Storage Devices',
pmg: 'Mail Gateway Thresholds',
agents: 'Agents',
agentDisks: 'Agent Disks',
agents: 'Systems',
agentDisks: 'System Disks',
dockerHosts: 'Container Runtimes',
dockerContainers: 'Containers',
});

View file

@ -19,7 +19,7 @@ export const ALERTS_PAGE_OVERVIEW_DESCRIPTION =
'Monitor active alerts, acknowledgements, and recent status changes across platforms.';
export const ALERTS_PAGE_THRESHOLDS_TITLE = 'Alert Thresholds';
export const ALERTS_PAGE_THRESHOLDS_DESCRIPTION =
'Tune resource thresholds and override rules for nodes, guests, and containers.';
'Tune threshold and override rules for infrastructure, systems, storage, and containers.';
export const ALERTS_PAGE_DESTINATIONS_TITLE = 'Notification Destinations';
export const ALERTS_PAGE_DESTINATIONS_DESCRIPTION =
'Configure email, webhooks, and escalation paths for alert delivery.';

View file

@ -1,6 +1,6 @@
export const PBS_THRESHOLDS_EMPTY_STATE = 'No PBS servers configured.';
export const GUEST_THRESHOLDS_EMPTY_STATE = 'No VMs or containers found.';
export const NODE_THRESHOLDS_FILTER_EMPTY_STATE = 'No nodes match the current filters.';
export const NODE_THRESHOLDS_FILTER_EMPTY_STATE = 'No virtualization hosts match the current filters.';
export const PBS_THRESHOLDS_FILTER_EMPTY_STATE = 'No PBS servers match the current filters.';
export const GUEST_THRESHOLDS_FILTER_EMPTY_STATE = 'No VMs or containers match the current filters.';
export const GUEST_FILTERING_EMPTY_STATE = 'Configure guest filtering rules.';
@ -11,10 +11,10 @@ export const STORAGE_THRESHOLDS_FILTER_EMPTY_STATE = 'No storage devices match t
export const PMG_THRESHOLDS_EMPTY_STATE =
'No mail gateways configured yet. Add a PMG instance in Settings to manage thresholds.';
export const PMG_THRESHOLDS_FILTER_EMPTY_STATE = 'No mail gateways match the current filters.';
export const AGENT_THRESHOLDS_FILTER_EMPTY_STATE = 'No agents match the current filters.';
export const AGENT_THRESHOLDS_FILTER_EMPTY_STATE = 'No systems match the current filters.';
export const AGENT_DISKS_EMPTY_STATE =
'No agent disks found. Agents with mounted filesystems will appear here.';
export const AGENT_DISKS_FILTER_EMPTY_STATE = 'No agent disks match the current filters.';
'No system disks found. Systems with mounted filesystems will appear here.';
export const AGENT_DISKS_FILTER_EMPTY_STATE = 'No system disks match the current filters.';
export const CONTAINER_RUNTIMES_FILTER_EMPTY_STATE =
'No container runtimes match the current filters.';
export const CONTAINERS_FILTER_EMPTY_STATE = 'No containers match the current filters.';
@ -61,7 +61,7 @@ export const ALERT_THRESHOLDS_DOCKER_SERVICES_CRITICAL_GAP_DESCRIPTION =
'Raise a critical alert when the missing replica gap meets or exceeds this value.';
export const ALERT_THRESHOLDS_DOCKER_SERVICES_GAP_VALIDATION_MESSAGE =
'Critical gap must be greater than or equal to the warning gap when enabled.';
export const ALERT_THRESHOLDS_SECTION_TITLE_NODES = 'Proxmox Nodes';
export const ALERT_THRESHOLDS_SECTION_TITLE_NODES = 'Virtualization Hosts';
export const ALERT_THRESHOLDS_SECTION_TITLE_PBS = 'PBS Servers';
export const ALERT_THRESHOLDS_SECTION_TITLE_GUESTS = 'VMs & Containers';
export const ALERT_THRESHOLDS_SECTION_TITLE_GUEST_FILTERING = 'Guest Filtering';
@ -69,8 +69,8 @@ export const ALERT_THRESHOLDS_SECTION_TITLE_BACKUPS = 'Recovery';
export const ALERT_THRESHOLDS_SECTION_TITLE_SNAPSHOTS = 'Snapshot Age';
export const ALERT_THRESHOLDS_SECTION_TITLE_STORAGE = 'Storage Devices';
export const ALERT_THRESHOLDS_SECTION_TITLE_PMG = 'Mail Gateway Thresholds';
export const ALERT_THRESHOLDS_SECTION_TITLE_AGENTS = 'Agents';
export const ALERT_THRESHOLDS_SECTION_TITLE_AGENT_DISKS = 'Agent Disks';
export const ALERT_THRESHOLDS_SECTION_TITLE_AGENTS = 'Systems';
export const ALERT_THRESHOLDS_SECTION_TITLE_AGENT_DISKS = 'System Disks';
export const ALERT_THRESHOLDS_SECTION_TITLE_DOCKER_HOSTS = 'Container Runtimes';
export const ALERT_THRESHOLDS_SECTION_TITLE_DOCKER_CONTAINERS = 'Containers';

View file

@ -49,16 +49,16 @@ test.describe.serial('Core E2E flows', () => {
await page.getByRole('button', { name: 'Thresholds' }).click();
await expect(page).toHaveURL(/\/alerts\/thresholds/);
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
// Proxmox Nodes section only appears when PVE nodes exist in unified resources.
// Virtualization Hosts only appears when PVE nodes exist in unified resources.
// In v6 the unified registry may not include PVE nodes — skip gracefully in that case.
const proxmoxNodesHeading = page.getByRole('heading', { name: 'Proxmox Nodes' });
const proxmoxNodesHeading = page.getByRole('heading', { name: 'Virtualization Hosts' });
const hasProxmoxNodes = await proxmoxNodesHeading.isVisible({ timeout: 5000 }).catch(() => false);
if (!hasProxmoxNodes) {
test.skip(true, 'Proxmox Nodes section not present (nodes not in unified resources)');
test.skip(true, 'Virtualization Hosts section not present (nodes not in unified resources)');
}
const proxmoxNodesSection = page
.getByRole('heading', { name: 'Proxmox Nodes' })
.getByRole('heading', { name: 'Virtualization Hosts' })
.locator('xpath=ancestor::*[.//table][1]');
const globalDefaultsRow = proxmoxNodesSection.locator('table tbody tr').filter({
@ -86,7 +86,7 @@ test.describe.serial('Core E2E flows', () => {
break;
}
if (targetRowIndex < 0) {
test.skip(true, 'No Proxmox node row without an existing override was found');
test.skip(true, 'No virtualization host row without an existing override was found');
}
const targetRow = nodeRows.nth(targetRowIndex);
@ -160,7 +160,7 @@ test.describe.serial('Core E2E flows', () => {
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
const rowAfterReload = page
.getByRole('heading', { name: 'Proxmox Nodes' })
.getByRole('heading', { name: 'Virtualization Hosts' })
.locator('xpath=ancestor::*[.//table][1]')
.locator('table tbody tr')
.filter({ hasText: resourceName })

View file

@ -0,0 +1,215 @@
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 SCREENSHOT_PATH = '/tmp/truenas-alert-thresholds.png';
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',
`truenas-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.describe('TrueNAS alert thresholds', () => {
test.setTimeout(180_000);
test('routes TrueNAS through the neutral infrastructure and systems thresholds surfaces', async ({
page,
}) => {
await page.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: 'truenas-resource',
type: 'truenas',
name: 'truenas-main',
displayName: 'TrueNAS Main',
platformId: 'truenas-main',
platformType: 'truenas',
sourceType: 'hybrid',
sources: ['agent', 'truenas'],
status: 'online',
lastSeen: '2026-03-29T22:00:00Z',
canonicalIdentity: {
displayName: 'TrueNAS Main',
hostname: 'truenas-main',
platformId: 'truenas-main',
},
identity: {
hostname: 'truenas-main',
},
agent: {
agentId: 'truenas-main',
hostname: 'truenas-main',
platform: 'TrueNAS SCALE',
disks: [
{
mountpoint: '/mnt/tank',
type: 'zfs',
used: 50 * 1024 * 1024 * 1024,
total: 100 * 1024 * 1024 * 1024,
},
],
},
platformData: {
sources: ['agent', 'truenas'],
agent: {
agentId: 'truenas-main',
disks: [
{
mountpoint: '/mnt/tank',
type: 'zfs',
used: 50 * 1024 * 1024 * 1024,
total: 100 * 1024 * 1024 * 1024,
},
],
},
},
},
{
id: 'storage-truenas-tank',
type: 'storage',
name: 'tank',
displayName: 'tank',
parentId: 'truenas-resource',
parentName: 'TrueNAS Main',
platformId: 'truenas-storage-1',
platformType: 'truenas',
sourceType: 'api',
sources: ['truenas'],
status: 'online',
lastSeen: '2026-03-29T22:00:00Z',
canonicalIdentity: {
displayName: 'tank',
platformId: 'truenas-storage-1',
},
storage: {
platform: 'truenas',
type: 'zfs-pool',
topology: 'pool',
isZfs: true,
},
platformData: {
node: 'truenas-main',
instance: 'tank',
sources: ['truenas'],
},
},
],
meta: {
page: 1,
limit: 200,
total: 2,
totalPages: 1,
},
}),
});
});
await page.route('**/api/alerts/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: true,
activationState: 'active',
overrides: {},
}),
});
});
await page.route('**/api/alerts/active', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.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.route('**/api/notifications/apprise', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: false,
}),
});
});
await page.goto('/alerts/thresholds/proxmox', {
waitUntil: 'domcontentloaded',
});
await expect(page).toHaveURL(/\/alerts\/thresholds\/infrastructure/);
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Infrastructure' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Storage Devices' })).toBeVisible();
await expect(page.getByText('tank', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Systems' }).click();
await expect(page).toHaveURL(/\/alerts\/thresholds\/systems/);
const systemsTable = page.locator('table').first();
const systemDisksSection = page.getByTestId('section-agentDisks');
await expect(page.getByRole('heading', { name: 'Systems' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'System Disks' })).toBeVisible();
await expect(systemsTable.getByText('TrueNAS Main', { exact: true })).toBeVisible();
await expect(systemDisksSection.getByText('TrueNAS Main', { exact: true })).toBeVisible();
await expect(systemDisksSection.getByText('/mnt/tank', { exact: true })).toBeVisible();
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
});