Lead alert thresholds with each group's default limits

The Thresholds page opened on a ten-select CPU evaluation grid, and
every resource group started collapsed showing only a name and a count,
so the limits that decide what alerts were invisible until a group was
opened one at a time.

Each collapsed group header now states its defaults ("CPU 80% · Memory
85% · Disk 90%"), built by one helper from the same columns and defaults
the group's table edits. It drops metrics the engine treats as off and
hides the line while the group is disabled. Rules whose zero means a
fallback are stated as whole rules with their effective values: Docker's
restart loop and memory-limit, and backup age by its alerting days
rather than the dashboard-only fresh/stale hours.

CPU averaging becomes one collapsed line stating the effective window
and how many profile overrides exist, with the same selects inside.
Docker and Machines list their groups before Docker-only rules and the
SMART card.
This commit is contained in:
rcourtman
2026-10-05 02:34:42 +01:00
parent 1d4c3aeb6e
commit 0f66f5c136
25 changed files with 606 additions and 102 deletions
@@ -2901,6 +2901,30 @@ severity and the triggered-in-24h total; threshold override counts are
configuration and do not appear on the overview. None of this changes alert
lifecycle, acknowledgement, snooze, or delivery evaluation.
### Thresholds lead with each group's default limits
The Thresholds page answers "what will alert me, and at what limits" before
any group is opened. Every collapsed threshold group whose table edits global
defaults states them in its header through
`getAlertThresholdsDefaultsSummary` in
`frontend-modern/src/utils/alertThresholdsSectionPresentation.ts`. The helper
reads the same column list and defaults object the group's resource table
edits (each section hoists its columns into one module constant), and drops
every metric `isAlertResourceMetricOff` reports, so the header can never show
a limit the engine is not evaluating. Rules whose zero means a fallback rather
than Off are stated as whole rules with their effective values: Docker's
restart loop (count within its window) and memory-limit warn/critical, which
`NormalizeDockerDefaults` always resolves to positive values. Backup age is
summarized by its warning and critical days only, because fresh and stale
hours colour the dashboard and never alert. A group switched off as a whole
shows its Disabled badge and no defaults line. The CPU averaging line counts
every configured profile override, including ones only the API sets. CPU averaging is tuning, not the page's
answer. It is one collapsed disclosure stating the effective window and the
number of platform overrides, with the same per-profile selects inside. Docker
and Machines list their resource groups before Docker-only rules and the
SMART card. Threshold values, inheritance, metric evaluation windows and
persistence are unchanged.
### External watchdog is Pulse-availability evidence, not notification delivery
The Alerts destinations surface owns the operator contract for an external
@@ -8357,3 +8357,11 @@ regressions preserve their original missing-value and fallback obligations.
drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light
and dark views. This is presentation acceptance, not native backup/QGA safety,
provider outage attribution, installed recovery or release availability.
### Threshold group headers carry a defaults line
`CollapsibleSection` takes an optional `defaultsSummary` rendered under the
title as a muted line (`data-thresholds-defaults-summary`) that wraps only
between items on phones and truncates from `sm` up, and is hidden while
`isGloballyDisabled` is set, where it would promise alerts that cannot fire. Callers pass the shared summary helper's output rather than
composing per-section strings.
@@ -0,0 +1,81 @@
{
"version": 1,
"base_sha": "1d4c3aeb6e529af3b63992ef22152e6b8b8a33d8",
"verified_at": "2026-10-05T01:34:30Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestDisksSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPMGSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx",
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx": "0a993dded673dbd2eb0b3919af2586a0fe4579ce10465e495710f526cf7ca1bd",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx": "d54ba2278c35ca5748cc0a310bb738dd9128454b1283d547bed7938568f92221",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "3b5cb92fb9b684d5655c6453b7e2738fb776d4c91bd566c319cc6d135301e095",
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx": "463bba443bc9dfc52e9cfcb83505640d5f8e91f5c731a8ec8cab1542f43e81a4",
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx": "3ee73f2b61ed5da9532dce15637e9803c4465f7a70241edf16e1692439c55fa5",
"frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx": "6226defd1e8adba0d28bae0998abd832993fc1e6b5b63f80024798c92684ecbd",
"frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx": "7e793356d6488169f8307ada64db35d9c15c9eacf1bddd30144e193f53345051",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx": "8b89d2d4e3ffcbfe1dceb6d4a5d309c1d424d1d58059110e51b9e73a93fcd670",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestDisksSection.tsx": "7849b5ed2d8c2674915aa98bcbd4bf348881efc155f00dbcbe9febeabcc38458",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx": "6f1694090e0022d1fff0653b06d1cf136f5fcebd59c88c41a4f703125d555ec2",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx": "c48d40917928e0ca8564d4eb90cb0e6987c8c9313494a444fa98209a3eee5dc1",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx": "d6801951e3d0ec6eaa7b267ff68e0f13ba3838be9c697b58822b94c57d719f06",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPMGSection.tsx": "2606881fdc7a68b747c34793330756b41513c89f07cbebcc5a3f69c6039d6610",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx": "3a1bfafe2b44c724a5b351d37d721bf5992db31d66e761a0b782ad6f0a57cc7c",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx": "c4da879c91150d4c8d32c2068a4a0988ac3934f9d7297bff45d63b2ee1f4f6e2",
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx": "2d78009148e366e1546d52127ea1b0d0ee372037ca9b627a0ff3605854a1ca53",
"frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx": "a628c3a17f16cb3ba74ca15bf53c0f8afa6d10390386b5b42f932c60105e167e",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx": "ea46c8920f008f1904fe6e51dbfc4c5fa755e5af92341fe223fe75428efdf077",
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts": "75c2c8d6e0728b0ecec30b02c92185af63a32f03c43cede6a7068cba29943112"
},
"routes": [
"/alerts/thresholds/proxmox",
"/alerts/thresholds/docker",
"/alerts/thresholds/kubernetes",
"/alerts/thresholds/truenas",
"/alerts/thresholds/vmware",
"/alerts/thresholds/systems"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Proxmox tab: every collapsed group states its default limits (Virtualization Hosts CPU 80% \u00b7 Memory 85% \u00b7 Disk 90% \u00b7 Temp 80\u00b0C, VMs & Containers, Guest Filesystems, Storage Devices, PBS Servers, Mail Gateway with +12 more)",
"Recovery and Snapshot Age groups disabled: Disabled badge and no defaults line",
"CPU averaging collapsed to one line: 5-minute average for every resource",
"Docker tab: Container Runtimes and Containers lead (Restart loop 3 in 5 min \u00b7 Memory limit 90% warn, 95% critical), Docker-only rules follow",
"Kubernetes, TrueNAS, vSphere tabs: every group with defaults states them",
"Machines tab: Machines and Machine Disks lead with defaults, SMART alert rules card follows",
"Phone 390: defaults lines and CPU averaging summary wrap between items without breaking a value",
"No console errors"
],
"interactions": [
"Opened CPU averaging disclosure: ten profile selects shown",
"Changed Virtual machines CPU averaging to 15-minute: summary updated to 1 platform override and the unsaved-changes banner appeared (not saved)",
"Expanded VMs & Containers to compare header defaults with the Global Defaults row"
]
}
@@ -5,7 +5,7 @@
* Used to organize resource groups in the thresholds page.
*/
import { Component, Show, createSignal, createEffect, JSX } from 'solid-js';
import { Component, For, Show, createSignal, createEffect, JSX } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import { StatusDot } from '@/components/shared/StatusDot';
@@ -34,6 +34,12 @@ export interface CollapsibleSectionProps {
icon?: JSX.Element;
/** Subtitle or description */
subtitle?: string;
/**
* What the group alerts on by default, shown under the title so the page
* answers "what will alert me" before the group is opened. Hidden while the
* group is disabled, where it would promise alerts that cannot fire.
*/
defaultsSummary?: string;
/** Message to show when section is empty */
emptyMessage?: string;
/** Whether to show a visual indicator for global disable state */
@@ -133,6 +139,23 @@ export const CollapsibleSection: Component<CollapsibleSectionProps> = (props) =>
<Show when={props.subtitle}>
<span class="block truncate text-sm font-normal text-muted">{props.subtitle}</span>
</Show>
<Show when={!props.isGloballyDisabled && props.defaultsSummary}>
<span
class="block text-xs font-normal tabular-nums text-muted sm:truncate sm:text-sm"
title={props.defaultsSummary}
data-thresholds-defaults-summary
>
{/* Break between items only, never inside "Disk 90%". */}
<For each={props.defaultsSummary?.split(' · ') ?? []}>
{(item, index) => (
<>
{index() > 0 ? ' · ' : ''}
<span class="whitespace-nowrap">{item}</span>
</>
)}
</For>
</span>
</Show>
</span>
</button>
</h3>
@@ -26,6 +26,29 @@ describe('CollapsibleSection', () => {
expect(screen.getByText('Child content')).toBeInTheDocument();
});
it('states the default limits in the collapsed header, except while disabled', () => {
const [disabled, setDisabled] = createSignal(false);
const { container } = render(() => (
<CollapsibleSection
id="guests"
title="VMs & Containers"
collapsed
defaultsSummary="Defaults: CPU 80% · Memory 85% · Disk 90%"
isGloballyDisabled={disabled()}
>
<p>Rows</p>
</CollapsibleSection>
));
const summary = () => container.querySelector('[data-thresholds-defaults-summary]');
expect(summary()?.textContent).toBe('Defaults: CPU 80% · Memory 85% · Disk 90%');
expect(summary()?.closest('button')?.getAttribute('aria-expanded')).toBe('false');
setDisabled(true);
expect(summary()).toBeNull();
expect(screen.getByText(ALERT_THRESHOLDS_SECTION_DISABLED_LABEL)).toBeInTheDocument();
});
it('uses default data-testid from id prop', () => {
const { container } = render(() => (
<CollapsibleSection id="vms" title="VMs">
@@ -5,6 +5,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const AGENT_DISK_THRESHOLD_COLUMNS = ['Disk %'];
export function ThresholdsTableAgentDisksSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +17,9 @@ export function ThresholdsTableAgentDisksSection(props: ThresholdsTableSectionPr
<CollapsibleSection
id="agentDisks"
title={state.sectionTitles.agentDisks}
defaultsSummary={getAlertThresholdsDefaultsSummary(AGENT_DISK_THRESHOLD_COLUMNS, {
disk: tableProps.agentDefaults.disk,
})}
resourceCount={state.agentDisksWithOverrides().length}
collapsed={state.isCollapsed('agentDisks')}
onToggle={() => state.toggleSection('agentDisks')}
@@ -27,7 +33,7 @@ export function ThresholdsTableAgentDisksSection(props: ThresholdsTableSectionPr
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.agentDisksGroupedByAgent()}
groupHeaderMeta={state.agentGroupHeaderMeta()}
columns={['Disk %']}
columns={AGENT_DISK_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.AGENT_DISKS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -6,6 +6,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const AGENT_THRESHOLD_COLUMNS = ['CPU %', 'Memory %', 'Disk %', 'Disk Temp °C'];
const DISK_TEMP_TYPE_FIELDS: readonly { key: string; label: string }[] = [
{ key: 'nvme', label: 'NVMe' },
@@ -21,6 +24,10 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
<CollapsibleSection
id="agents"
title={state.sectionTitles.agents}
defaultsSummary={getAlertThresholdsDefaultsSummary(
AGENT_THRESHOLD_COLUMNS,
tableProps.agentDefaults,
)}
resourceCount={state.agentsWithOverrides().length}
collapsed={state.isCollapsed('agents')}
onToggle={() => state.toggleSection('agents')}
@@ -33,7 +40,7 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.agentsWithOverrides()}
columns={['CPU %', 'Memory %', 'Disk %', 'Disk Temp °C']}
columns={AGENT_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.AGENT_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -6,9 +6,10 @@ import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/t
export function ThresholdsTableAgentsTab(props: ThresholdsTableSectionProps) {
return (
<>
<ThresholdsTableSMARTDefaultsCard {...props} />
{/* The groups and their default limits lead; disk health rules follow. */}
<ThresholdsTableAgentsResourcesSection {...props} />
<ThresholdsTableAgentDisksSection {...props} />
<ThresholdsTableSMARTDefaultsCard {...props} />
</>
);
}
@@ -5,15 +5,46 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import {
getAlertThresholdsDefaultsSummary,
getDockerContainerRuleSummaryItems,
} from '@/utils/alertThresholdsSectionPresentation';
const DOCKER_CONTAINER_THRESHOLD_COLUMNS = [
'CPU %',
'Memory %',
'Disk %',
'Restart Count',
'Restart Window (s)',
'Memory Warn %',
'Memory Critical %',
];
// Restart and memory-limit columns are summarized as whole rules instead.
const DOCKER_CONTAINER_METRIC_SUMMARY_COLUMNS = ['CPU %', 'Memory %', 'Disk %'];
export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
const containerDefaults = () => ({
cpu: tableProps.dockerDefaults.cpu,
memory: tableProps.dockerDefaults.memory,
disk: tableProps.dockerDefaults.disk,
restartCount: tableProps.dockerDefaults.restartCount,
restartWindow: tableProps.dockerDefaults.restartWindow,
memoryWarnPct: tableProps.dockerDefaults.memoryWarnPct,
memoryCriticalPct: tableProps.dockerDefaults.memoryCriticalPct,
});
return (
<Show when={state.hasSection('dockerContainers')}>
<CollapsibleSection
id="dockerContainers"
title={state.sectionTitles.dockerContainers}
defaultsSummary={getAlertThresholdsDefaultsSummary(
DOCKER_CONTAINER_METRIC_SUMMARY_COLUMNS,
containerDefaults(),
{ ruleItems: getDockerContainerRuleSummaryItems(containerDefaults()) },
)}
resourceCount={state.dockerContainersFlat().length}
collapsed={state.isCollapsed('dockerContainers')}
onToggle={() => state.toggleSection('dockerContainers')}
@@ -27,15 +58,7 @@ export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSec
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.dockerContainersGroupedByHost()}
groupHeaderMeta={state.dockerHostGroupMeta()}
columns={[
'CPU %',
'Memory %',
'Disk %',
'Restart Count',
'Restart Window (s)',
'Memory Warn %',
'Memory Critical %',
]}
columns={DOCKER_CONTAINER_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.CONTAINERS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -54,15 +77,7 @@ export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSec
}
formatMetricValue={formatMetricValue}
hasActiveAlert={state.hasActiveAlert}
globalDefaults={{
cpu: tableProps.dockerDefaults.cpu,
memory: tableProps.dockerDefaults.memory,
disk: tableProps.dockerDefaults.disk,
restartCount: tableProps.dockerDefaults.restartCount,
restartWindow: tableProps.dockerDefaults.restartWindow,
memoryWarnPct: tableProps.dockerDefaults.memoryWarnPct,
memoryCriticalPct: tableProps.dockerDefaults.memoryCriticalPct,
}}
globalDefaults={containerDefaults()}
setGlobalDefaults={(value) => {
const current = {
cpu: tableProps.dockerDefaults.cpu,
@@ -10,13 +10,14 @@ import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/t
export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) {
return (
<>
{/* The groups and their default limits lead; Docker-only rules follow. */}
<ThresholdsTableDockerHostsSection {...props} />
<ThresholdsTableDockerContainersSection {...props} />
<Show when={props.state.hasDockerSpecificControls()}>
<ThresholdsTableDockerIgnoredPrefixesSection {...props} />
<ThresholdsTableDockerUpdateAlertsSection {...props} />
<ThresholdsTableDockerServiceGapSection {...props} />
</Show>
<ThresholdsTableDockerHostsSection {...props} />
<ThresholdsTableDockerContainersSection {...props} />
</>
);
}
@@ -10,6 +10,7 @@ import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const KUBERNETES_WORKLOAD_COLUMNS = [
'CPU %',
@@ -40,6 +41,7 @@ function KubernetesResourceSection(
<CollapsibleSection
id={props.id}
title={props.title}
defaultsSummary={getAlertThresholdsDefaultsSummary(props.columns, props.defaults)}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
@@ -15,6 +15,11 @@ import {
reconcileWarningCriticalEdit,
} from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const BACKUP_THRESHOLD_COLUMNS = ['Fresh Hours', 'Stale Hours', 'Warning Days', 'Critical Days'];
// Fresh and stale hours only colour the dashboard; the alert fires on days.
const BACKUP_ALERT_SUMMARY_COLUMNS = ['Warning Days', 'Critical Days'];
export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -24,6 +29,10 @@ export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSecti
<CollapsibleSection
id="backups"
title={state.sectionTitles.backups}
defaultsSummary={getAlertThresholdsDefaultsSummary(
BACKUP_ALERT_SUMMARY_COLUMNS,
state.backupDefaultsRecord(),
)}
collapsed={state.isCollapsed('backups')}
onToggle={() => state.toggleSection('backups')}
icon={<Archive class="w-5 h-5" />}
@@ -33,7 +42,7 @@ export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSecti
<div ref={state.registerSection('backups')} class="scroll-mt-24">
<ResourceTable
title=""
columns={['Fresh Hours', 'Stale Hours', 'Warning Days', 'Critical Days']}
columns={BACKUP_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
onEdit={state.startEditing}
onSaveEdit={state.saveEdit}
@@ -5,6 +5,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const GUEST_DISK_THRESHOLD_COLUMNS = ['Disk %'];
export function ThresholdsTableProxmoxGuestDisksSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +17,9 @@ export function ThresholdsTableProxmoxGuestDisksSection(props: ThresholdsTableSe
<CollapsibleSection
id="guestDisks"
title={state.sectionTitles.guestDisks}
defaultsSummary={getAlertThresholdsDefaultsSummary(GUEST_DISK_THRESHOLD_COLUMNS, {
disk: tableProps.guestDefaults.disk,
})}
resourceCount={state.guestDisksWithOverrides().length}
collapsed={state.isCollapsed('guestDisks')}
onToggle={() => state.toggleSection('guestDisks')}
@@ -26,7 +32,7 @@ export function ThresholdsTableProxmoxGuestDisksSection(props: ThresholdsTableSe
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.guestDisksGroupedByGuest()}
columns={['Disk %']}
columns={GUEST_DISK_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.GUEST_DISKS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -5,6 +5,19 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const GUEST_THRESHOLD_COLUMNS = [
'CPU %',
'Memory %',
'Disk %',
'Backup',
'Snapshot',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
];
export function ThresholdsTableProxmoxGuestsSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +27,10 @@ export function ThresholdsTableProxmoxGuestsSection(props: ThresholdsTableSectio
<CollapsibleSection
id="guests"
title={state.sectionTitles.guests}
defaultsSummary={getAlertThresholdsDefaultsSummary(
GUEST_THRESHOLD_COLUMNS,
tableProps.guestDefaults,
)}
resourceCount={tableProps.allGuests().length}
collapsed={state.isCollapsed('guests')}
onToggle={() => state.toggleSection('guests')}
@@ -27,17 +44,7 @@ export function ThresholdsTableProxmoxGuestsSection(props: ThresholdsTableSectio
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.guestsGroupedByNode()}
groupHeaderMeta={state.guestGroupHeaderMeta()}
columns={[
'CPU %',
'Memory %',
'Disk %',
'Backup',
'Snapshot',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
]}
columns={GUEST_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.GUEST_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -5,6 +5,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const NODE_THRESHOLD_COLUMNS = ['CPU %', 'Memory %', 'Disk %', 'Temp °C'];
export function ThresholdsTableProxmoxNodesSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +17,10 @@ export function ThresholdsTableProxmoxNodesSection(props: ThresholdsTableSection
<CollapsibleSection
id="nodes"
title={state.sectionTitles.nodes}
defaultsSummary={getAlertThresholdsDefaultsSummary(
NODE_THRESHOLD_COLUMNS,
tableProps.nodeDefaults,
)}
resourceCount={state.nodesWithOverrides().length}
collapsed={state.isCollapsed('nodes')}
onToggle={() => state.toggleSection('nodes')}
@@ -26,7 +33,7 @@ export function ThresholdsTableProxmoxNodesSection(props: ThresholdsTableSection
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.nodesWithOverrides()}
columns={['CPU %', 'Memory %', 'Disk %', 'Temp °C']}
columns={NODE_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.NODE_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -5,6 +5,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const PBS_THRESHOLD_COLUMNS = ['CPU %', 'Memory %'];
export function ThresholdsTableProxmoxPBSSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +17,10 @@ export function ThresholdsTableProxmoxPBSSection(props: ThresholdsTableSectionPr
<CollapsibleSection
id="pbs"
title={state.sectionTitles.pbs}
defaultsSummary={getAlertThresholdsDefaultsSummary(
PBS_THRESHOLD_COLUMNS,
tableProps.pbsDefaults ?? { cpu: 80, memory: 85 },
)}
resourceCount={state.pbsServersWithOverrides().length}
collapsed={state.isCollapsed('pbs')}
onToggle={() => state.toggleSection('pbs')}
@@ -26,7 +33,7 @@ export function ThresholdsTableProxmoxPBSSection(props: ThresholdsTableSectionPr
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.pbsServersWithOverrides()}
columns={['CPU %', 'Memory %']}
columns={PBS_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.PBS_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -5,6 +5,26 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const PMG_THRESHOLD_TABLE_COLUMNS = [
'Queue Warn',
'Queue Crit',
'Deferred Warn',
'Deferred Crit',
'Hold Warn',
'Hold Crit',
'Oldest Warn (min)',
'Oldest Crit (min)',
'Spam Warn',
'Spam Crit',
'Virus Warn',
'Virus Crit',
'Growth Warn %',
'Growth Warn Min',
'Growth Crit %',
'Growth Crit Min',
];
export function ThresholdsTableProxmoxPMGSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +34,10 @@ export function ThresholdsTableProxmoxPMGSection(props: ThresholdsTableSectionPr
<CollapsibleSection
id="pmg"
title={state.sectionTitles.pmg}
defaultsSummary={getAlertThresholdsDefaultsSummary(
PMG_THRESHOLD_TABLE_COLUMNS,
state.pmgGlobalDefaults(),
)}
resourceCount={state.pmgServersWithOverrides().length}
collapsed={state.isCollapsed('pmg')}
onToggle={() => state.toggleSection('pmg')}
@@ -26,24 +50,7 @@ export function ThresholdsTableProxmoxPMGSection(props: ThresholdsTableSectionPr
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.pmgServersWithOverrides()}
columns={[
'Queue Warn',
'Queue Crit',
'Deferred Warn',
'Deferred Crit',
'Hold Warn',
'Hold Crit',
'Oldest Warn (min)',
'Oldest Crit (min)',
'Spam Warn',
'Spam Crit',
'Virus Warn',
'Virus Crit',
'Growth Warn %',
'Growth Warn Min',
'Growth Crit %',
'Growth Crit Min',
]}
columns={PMG_THRESHOLD_TABLE_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.PMG_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -8,6 +8,14 @@ import {
reconcileWarningCriticalEdit,
} from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const SNAPSHOT_THRESHOLD_COLUMNS = [
'Warning Days',
'Critical Days',
'Warning Size (GiB)',
'Critical Size (GiB)',
];
export function ThresholdsTableProxmoxSnapshotsSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -17,6 +25,10 @@ export function ThresholdsTableProxmoxSnapshotsSection(props: ThresholdsTableSec
<CollapsibleSection
id="snapshots"
title={state.sectionTitles.snapshots}
defaultsSummary={getAlertThresholdsDefaultsSummary(
SNAPSHOT_THRESHOLD_COLUMNS,
state.snapshotDefaultsRecord(),
)}
collapsed={state.isCollapsed('snapshots')}
onToggle={() => state.toggleSection('snapshots')}
icon={<Camera class="w-5 h-5" />}
@@ -26,7 +38,7 @@ export function ThresholdsTableProxmoxSnapshotsSection(props: ThresholdsTableSec
<div ref={state.registerSection('snapshots')} class="scroll-mt-24">
<ResourceTable
title=""
columns={['Warning Days', 'Critical Days', 'Warning Size (GiB)', 'Critical Size (GiB)']}
columns={SNAPSHOT_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
onEdit={state.startEditing}
onSaveEdit={state.saveEdit}
@@ -5,6 +5,9 @@ import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const STORAGE_THRESHOLD_COLUMNS = ['Usage %'];
export function ThresholdsTableProxmoxStorageSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -14,6 +17,9 @@ export function ThresholdsTableProxmoxStorageSection(props: ThresholdsTableSecti
<CollapsibleSection
id="storage"
title={state.sectionTitles.storage}
defaultsSummary={getAlertThresholdsDefaultsSummary(STORAGE_THRESHOLD_COLUMNS, {
usage: tableProps.storageDefault(),
})}
resourceCount={tableProps.storage.length}
collapsed={state.isCollapsed('storage')}
onToggle={() => state.toggleSection('storage')}
@@ -27,7 +33,7 @@ export function ThresholdsTableProxmoxStorageSection(props: ThresholdsTableSecti
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.storageGroupedByNode()}
groupHeaderMeta={state.guestGroupHeaderMeta()}
columns={['Usage %']}
columns={STORAGE_THRESHOLD_COLUMNS}
activeAlerts={tableProps.activeAlerts}
emptyMessage={state.STORAGE_THRESHOLDS_FILTER_EMPTY_STATE}
onEdit={state.startEditing}
@@ -8,6 +8,7 @@ import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const TRUENAS_SYSTEM_COLUMNS = [
'CPU %',
@@ -47,6 +48,7 @@ function TrueNASResourceSection(
<CollapsibleSection
id={props.id}
title={props.title}
defaultsSummary={getAlertThresholdsDefaultsSummary(props.columns, props.defaults)}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
@@ -9,6 +9,7 @@ import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const VMWARE_HOST_COLUMNS = [
'CPU %',
@@ -54,6 +55,7 @@ function VMwareResourceSection(
<CollapsibleSection
id={props.id}
title={props.title}
defaultsSummary={getAlertThresholdsDefaultsSummary(props.columns, props.defaults)}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
@@ -342,6 +342,18 @@ describe('ThresholdsTab', () => {
});
});
it('counts API-only CPU averaging overrides in the collapsed summary', () => {
const props = buildProps();
props.metricEvaluationWindows = () => ({ all: { cpu: 300 }, pod: { cpu: 60 } });
props.setMetricEvaluationWindows = vi.fn();
render(() => <ThresholdsTab {...props} />);
expect(document.querySelector('[data-cpu-averaging-summary]')?.textContent).toBe(
'5-minute average, 1 platform override',
);
});
it('configures the canonical workload fallback and shows effective inheritance', () => {
const props = buildProps();
const setMetricEvaluationWindows = vi.fn();
@@ -356,12 +368,16 @@ describe('ThresholdsTab', () => {
render(() => <ThresholdsTab {...props} />);
const workloadWindow = screen.getAllByLabelText(
'All workloads CPU evaluation window',
'All workloads CPU averaging',
)[0] as HTMLSelectElement;
const virtualMachineWindow = screen.getAllByLabelText(
'Virtual machines CPU evaluation window',
'Virtual machines CPU averaging',
)[0] as HTMLSelectElement;
expect(workloadWindow.value).toBe('900');
expect(document.querySelector('[data-cpu-averaging]')?.hasAttribute('open')).toBe(false);
expect(document.querySelector('[data-cpu-averaging-summary]')?.textContent).toBe(
'5-minute average, 1 platform override',
);
expect(virtualMachineWindow.options[0].textContent).toBe('Inherit (15 minutes)');
fireEvent.input(workloadWindow, { target: { value: '60' } });
@@ -1,5 +1,5 @@
import { createSignal, For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import {
AlertIntentPolicyPanel,
@@ -76,16 +76,31 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
});
props.setHasUnsavedChanges(true);
};
const averageLabel = (seconds: number) => {
if (seconds === 0) return 'Current value, no averaging';
if (seconds < 60) return `${seconds}-second average`;
return `${seconds / 60}-minute average`;
};
const cpuWindowSummary = () => {
const windows = props.metricEvaluationWindows?.() ?? {};
const lead = averageLabel(windows.all?.cpu ?? 300);
// Count every configured profile, including ones only the API sets (such
// as pod), so the line never claims one window for every resource.
const overrides = Object.entries(windows).filter(
([key, profile]) => key !== 'all' && profile?.cpu !== undefined,
).length;
return overrides === 0
? `${lead} for every resource`
: `${lead}, ${overrides} platform override${overrides === 1 ? '' : 's'}`;
};
const evaluationProfileControl = (profile: (typeof evaluationProfiles)[number]) => {
const configured = () => props.metricEvaluationWindows?.()[profile.key]?.cpu;
return (
<label class="block rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">
{profile.label}
</span>
<label class="block rounded-md border border-border p-3">
<span class="block text-xs font-medium text-base-content">{profile.label}</span>
<select
aria-label={`${profile.label} CPU evaluation window`}
class="mt-2 w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
aria-label={`${profile.label} CPU averaging`}
class="mt-2 w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-base-content"
value={
profile.key === 'all'
? String(configured() ?? 300)
@@ -101,9 +116,9 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
</option>
</Show>
<option value="0">Current value</option>
<option value="60">1 minute average</option>
<option value="300">5 minute average</option>
<option value="900">15 minute average</option>
<option value="60">1-minute average</option>
<option value="300">5-minute average</option>
<option value="900">15-minute average</option>
</select>
</label>
);
@@ -115,44 +130,38 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
resources={props.allResources}
selectionTarget={intentSelectionTarget()}
/>
{/*
CPU averaging is tuning, not the page's answer. It stays one line that
states the effective setting, so the default limits below lead and the
ten per-platform selects open only for someone changing them.
*/}
<Show when={props.metricEvaluationWindows && props.setMetricEvaluationWindows}>
<Card padding="md">
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
CPU evaluation window
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Pulse averages CPU over time before applying trigger and recovery thresholds. This
filters harmless bursts without delaying a sustained incident or splitting its
timeline.
</p>
</div>
<details class="group rounded-md border border-border bg-surface" data-cpu-averaging>
<summary class="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-sm focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500 [&::-webkit-details-marker]:hidden">
<ChevronRight
class="h-4 w-4 shrink-0 text-muted transition-transform group-open:rotate-90"
aria-hidden="true"
/>
<span class="shrink-0 font-semibold text-base-content">CPU averaging</span>
<span class="min-w-0 text-muted sm:truncate" data-cpu-averaging-summary>
{cpuWindowSummary()}
</span>
</summary>
<div class="space-y-3 border-t border-border px-4 py-3">
<p class="text-sm text-muted">
Pulse averages CPU over time before applying trigger and recovery thresholds. This
filters harmless bursts without delaying a sustained incident or splitting its
timeline.
</p>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={evaluationProfiles}>
{(profile) => (
<div class={profile.key === 'all' ? 'block' : 'hidden sm:block'}>
{evaluationProfileControl(profile)}
</div>
)}
</For>
<For each={evaluationProfiles}>{(profile) => evaluationProfileControl(profile)}</For>
</div>
<details class="rounded-lg border border-gray-200 p-3 sm:hidden dark:border-gray-700">
<summary class="cursor-pointer text-sm font-medium text-gray-800 dark:text-gray-200">
Platform overrides
</summary>
<div class="mt-3 space-y-3">
<For each={evaluationProfiles.slice(1)}>
{(profile) => evaluationProfileControl(profile)}
</For>
</div>
</details>
<p class="text-xs text-gray-500 dark:text-gray-400">
<p class="text-xs text-muted">
A rolling rule waits for enough recent, gap-free samples. If history is incomplete,
Pulse holds the existing incident state instead of firing or resolving on weak data.
</p>
</div>
</Card>
</details>
</Show>
<ThresholdsTable
onConfigureResourceIntent={configureResourceIntent}
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import {
ALERT_THRESHOLDS_SECTION_DISABLED_LABEL,
ALERT_THRESHOLDS_SECTION_UNSAVED_CHANGES_TITLE,
getAlertThresholdsDefaultsSummary,
getAlertThresholdsSectionDisabledLabel,
getDockerContainerRuleSummaryItems,
getAlertThresholdsSectionUnsavedChangesTitle,
} from '@/utils/alertThresholdsSectionPresentation';
@@ -13,4 +15,98 @@ describe('alertThresholdsSectionPresentation', () => {
expect(getAlertThresholdsSectionDisabledLabel()).toBe('Disabled');
expect(getAlertThresholdsSectionUnsavedChangesTitle()).toBe('Unsaved changes');
});
it('names the enabled default limits a threshold group alerts on', () => {
expect(
getAlertThresholdsDefaultsSummary(
['CPU %', 'Memory %', 'Disk %', 'Backup', 'Snapshot', 'Disk R MB/s', 'Net In MB/s'],
{ cpu: 80, memory: 85, disk: 90, diskRead: 0, networkIn: -1 },
),
).toBe('Defaults: CPU 80% · Memory 85% · Disk 90%');
expect(
getAlertThresholdsDefaultsSummary(
['CPU %', 'Memory %', 'Disk %', 'Disk R MB/s', 'Net In MB/s'],
{
cpu: 80,
memory: 85,
disk: 90,
diskRead: 50,
networkIn: 100,
},
),
).toBe('Defaults: CPU 80% · Memory 85% · Disk 90% · Disk read 50 MB/s · +1 more');
});
it('reads day, hour, size and percent units from the column label', () => {
expect(
getAlertThresholdsDefaultsSummary(
['Warning Days', 'Critical Days', 'Warning Size (GiB)', 'Critical Size (GiB)'],
{ 'warning days': 30, 'critical days': 1, warningSizeGiB: 0, criticalSizeGiB: 0 },
),
).toBe('Defaults: Warning 30 days · Critical 1 day');
expect(
getAlertThresholdsDefaultsSummary(['Fresh Hours', 'Stale Hours'], {
'fresh hours': 24,
'stale hours': 72,
}),
).toBe('Defaults: Fresh 24 hours · Stale 72 hours');
expect(
getAlertThresholdsDefaultsSummary(['Restart Count', 'Restart Window (s)', 'Memory Warn %'], {
restartCount: 3,
restartWindow: 300,
memoryWarnPct: 90,
}),
).toBe('Defaults: Restart Count 3 · Restart Window 300s · Memory Warn 90%');
});
it('says when every metric alert is off and stays silent without numeric defaults', () => {
expect(getAlertThresholdsDefaultsSummary(['CPU %', 'Memory %'], { cpu: 0, memory: -1 })).toBe(
'Defaults: all metric alerts off',
);
expect(getAlertThresholdsDefaultsSummary([], { cpu: 80 })).toBeUndefined();
expect(getAlertThresholdsDefaultsSummary(['Backup'], { cpu: 80 })).toBeUndefined();
expect(getAlertThresholdsDefaultsSummary(['CPU %'], undefined)).toBeUndefined();
});
it('states Docker restart-loop and memory-limit rules with their effective fallbacks', () => {
expect(
getDockerContainerRuleSummaryItems({
restartCount: 3,
restartWindow: 300,
memoryWarnPct: 90,
memoryCriticalPct: 95,
}),
).toEqual(['Restart loop 3 in 5 min', 'Memory limit 90% warn, 95% critical']);
// Config normalization replaces <= 0 with the fallbacks, so these rules
// never read as Off.
expect(
getDockerContainerRuleSummaryItems({
restartCount: -1,
restartWindow: 0,
memoryWarnPct: 0,
memoryCriticalPct: -1,
}),
).toEqual(['Restart loop 3 in 5 min', 'Memory limit 90% warn, 95% critical']);
expect(getDockerContainerRuleSummaryItems({ restartCount: 5, restartWindow: 90 })[0]).toBe(
'Restart loop 5 in 90s',
);
expect(
getAlertThresholdsDefaultsSummary(
['CPU %', 'Memory %', 'Disk %'],
{ cpu: 0, memory: 0, disk: 0 },
{ ruleItems: getDockerContainerRuleSummaryItems({}) },
),
).toBe('Defaults: Restart loop 3 in 5 min · Memory limit 90% warn, 95% critical');
});
it('summarizes backup alerts by their day thresholds only', () => {
expect(
getAlertThresholdsDefaultsSummary(['Warning Days', 'Critical Days'], {
'fresh hours': 24,
'stale hours': 72,
'warning days': 0,
'critical days': 0,
}),
).toBe('Defaults: all metric alerts off');
});
});
@@ -1,3 +1,9 @@
import {
isAlertResourceMetricOff,
normalizeAlertResourceMetricKey,
} from '@/components/Alerts/alertResourceTableModel';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
export const ALERT_THRESHOLDS_SECTION_DISABLED_LABEL = 'Disabled';
export const ALERT_THRESHOLDS_SECTION_UNSAVED_CHANGES_TITLE = 'Unsaved changes';
@@ -8,3 +14,124 @@ export function getAlertThresholdsSectionDisabledLabel() {
export function getAlertThresholdsSectionUnsavedChangesTitle() {
return ALERT_THRESHOLDS_SECTION_UNSAVED_CHANGES_TITLE;
}
const ALERT_THRESHOLDS_DEFAULTS_SUMMARY_MAX_ITEMS = 4;
type DefaultsSummaryUnit = {
suffix: string;
format: (key: string, value: number, label: string) => string;
};
// Unit suffixes as they appear in threshold column labels. The label loses the
// suffix and the value gains it, so "CPU %" at 80 reads "CPU 80%".
const DEFAULTS_SUMMARY_UNITS: DefaultsSummaryUnit[] = [
{ suffix: ' %', format: (key, value, label) => `${label} ${formatPercent(key, value)}` },
{ suffix: ' °C', format: (key, value, label) => `${label} ${formatMetricValue(key, value)}` },
{ suffix: ' MB/s', format: (_key, value, label) => `${label} ${value} MB/s` },
{ suffix: ' (GiB)', format: (key, value, label) => `${label} ${formatMetricValue(key, value)}` },
{ suffix: ' (s)', format: (_key, value, label) => `${label} ${value}s` },
{ suffix: ' (min)', format: (_key, value, label) => `${label} ${value} min` },
{ suffix: ' Days', format: (_key, value, label) => `${label} ${value} ${plural(value, 'day')}` },
{
suffix: ' Hours',
format: (_key, value, label) => `${label} ${value} ${plural(value, 'hour')}`,
},
];
const DEFAULTS_SUMMARY_LABELS: Record<string, string> = {
'Disk R': 'Disk read',
'Disk W': 'Disk write',
'Net In': 'Net in',
'Net Out': 'Net out',
'Disk Temp': 'Disk temp',
};
const formatPercent = (key: string, value: number) => {
const formatted = formatMetricValue(key, value);
return formatted.endsWith('%') ? formatted : `${formatted}%`;
};
const plural = (value: number, unit: string) => (value === 1 ? unit : `${unit}s`);
const describeDefault = (column: string, value: number): string => {
const key = normalizeAlertResourceMetricKey(column);
const trimmed = column.trim();
for (const unit of DEFAULTS_SUMMARY_UNITS) {
if (trimmed.endsWith(unit.suffix)) {
const base = trimmed.slice(0, -unit.suffix.length);
return unit.format(key, value, DEFAULTS_SUMMARY_LABELS[base] ?? base);
}
}
return `${DEFAULTS_SUMMARY_LABELS[trimmed] ?? trimmed} ${formatMetricValue(key, value)}`;
};
// One line naming what a threshold group alerts on by default, so the page
// answers "what will alert me" before any group is opened. It reads the same
// columns and defaults the group's table edits. Metrics the engine treats as
// off (isAlertResourceMetricOff, trigger <= 0) are left out. Columns with no
// numeric default, such as the Backup and Snapshot toggles, are skipped.
// `ruleItems` carries rules that are not plain per-metric limits and are
// already described in words (see getDockerContainerRuleSummaryItems).
export function getAlertThresholdsDefaultsSummary(
columns: readonly string[],
defaults: Record<string, number | undefined> | undefined,
options: { ruleItems?: readonly string[]; maxItems?: number } = {},
): string | undefined {
if (!defaults) return undefined;
const ruleItems = options.ruleItems ?? [];
const numericColumns = columns.filter((column) => {
const value = defaults[normalizeAlertResourceMetricKey(column)];
return typeof value === 'number' && Number.isFinite(value);
});
if (numericColumns.length === 0 && ruleItems.length === 0) return undefined;
const items = [
...numericColumns
.filter(
(column) => !isAlertResourceMetricOff(defaults[normalizeAlertResourceMetricKey(column)]),
)
.map((column) => describeDefault(column, defaults[normalizeAlertResourceMetricKey(column)]!)),
...ruleItems,
];
if (items.length === 0) return 'Defaults: all metric alerts off';
const maxItems = options.maxItems ?? ALERT_THRESHOLDS_DEFAULTS_SUMMARY_MAX_ITEMS;
const shown = items.slice(0, maxItems);
const hidden = items.length - shown.length;
return `Defaults: ${shown.join(' · ')}${hidden > 0 ? ` · +${hidden} more` : ''}`;
}
// Docker's restart-loop and memory-limit rules always run: config
// normalization (internal/alerts/config/normalize.go, NormalizeDockerDefaults)
// replaces any value <= 0 with these fallbacks. So the summary states the
// effective rule rather than treating zero as Off, and pairs the restart count
// with its window because neither means anything alone.
const DOCKER_RULE_FALLBACKS = {
restartCount: 3,
restartWindow: 300,
memoryWarnPct: 90,
memoryCriticalPct: 95,
} as const;
const effectiveDockerRule = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
const formatRestartWindow = (seconds: number) =>
seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds}s`;
export function getDockerContainerRuleSummaryItems(defaults: {
restartCount?: number;
restartWindow?: number;
memoryWarnPct?: number;
memoryCriticalPct?: number;
}): string[] {
const restarts = effectiveDockerRule(defaults.restartCount, DOCKER_RULE_FALLBACKS.restartCount);
const window = effectiveDockerRule(defaults.restartWindow, DOCKER_RULE_FALLBACKS.restartWindow);
const warn = effectiveDockerRule(defaults.memoryWarnPct, DOCKER_RULE_FALLBACKS.memoryWarnPct);
const critical = effectiveDockerRule(
defaults.memoryCriticalPct,
DOCKER_RULE_FALLBACKS.memoryCriticalPct,
);
return [
`Restart loop ${restarts} in ${formatRestartWindow(window)}`,
`Memory limit ${warn}% warn, ${critical}% critical`,
];
}