Retain reviewed monitoring repairs alongside upstream alert and backup guidance

Compose the exact current upstream head additively, preserving reviewed local ancestry and independently landed alert and backup presentation.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 03:05:13 +01:00
36 changed files with 867 additions and 351 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
@@ -7453,6 +7453,11 @@ presentation. The compact row remains actionable: plain-language rationale and
provider history/permission limitations live one disclosure deeper beside
bounded restore evidence. A posture fetch failure must keep the evidence
inspectable and show unknown, never a locally inferred healthy fallback.
The bounded restore evidence keeps the newest point and the newest completed
point of each source before filling with the newest overall, so a run of guest
snapshots or a running job cannot push the latest usable backup out of the
disclosure. A coverage strip footnote is
plain muted text under the legend, not another counter or badge row.
Protection table controls continue to compose shared filter, table, status,
counter, loading, error, disclosure, and inline-detail primitives. The bounded
@@ -8372,3 +8377,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.
@@ -171,6 +171,16 @@ backup files, never guest-local snapshots. Guest snapshots remain listed as
restore evidence but cannot make an old independent backup look current.
Archive detail uses its format (or filename fallback), preserving the full
provider volid for hover and search in both Coverage and By date.
Coverage rows carry that one counted backup age at every width; per-source
PBS, PVE-file and guest-snapshot age columns are not row columns, because a
fresh snapshot beside a stale backup read as protection that posture does not
grant. The row expansion keeps the newest point and the newest completed point
of every source inside its bounded evidence list, so neither recent snapshots
nor a running or failed job hide the latest usable backup. The Backup health
strip states what its attention, unprotected and unknown counts mean from the
server posture policy (`freshnessWindowSeconds`) using the causes
`internal/recovery/posture.go` evaluates, and renders no rule until that policy
has loaded rather than a guessed window.
Phone pool/disk rows preserve health and identity while using weighted widths,
consistent gutters and compact capacity/health labels. Full disk risk reasons
@@ -3313,7 +3313,9 @@ metadata.
### Protection posture identity consumer
`ProxmoxCoverageTable` remains a unified-resource identity consumer while
storage/recovery owns protection truth. Live VM/LXC rows carry the exact
storage/recovery owns protection truth. Dropping the per-source age columns
from its rows changes no identity input: rows still key posture by the exact
canonical `Resource.id`. Live VM/LXC rows carry the exact
canonical `Resource.id` into one bounded posture batch; the table must not parse
its presentation key, VMID, name, node, or instance to mint a replacement
resource identity. Host and orphaned backup artifacts have no canonical live
@@ -319,9 +319,6 @@ function Fixture() {
: new Set([...current, key]),
)
}
showPbsColumn
showArchiveColumn
showSnapshotColumn
showTaskColumn={false}
/>
<h2>By date</h2>
@@ -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"
]
}
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "4c266391c3753eaa1e6bea1cfd8339c5d40c01de",
"verified_at": "2026-10-05T01:10:06Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx": "97d3f7a23a959406dd8cb9e92a8921fd38118194d5f757e31b6e0e2eec74f27f",
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "2cd4cfb0c470ba51efcdb0beafef0b630acac34373b92dd9b77de03f99b16743",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "2568f261531b4b601df7da9e1b8c1826bacc70e8a28420bbe909f2eb3524c6eb",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts": "8f7f8a80bb994404c21cce7b75c621bdf844f80b38a9def79d6dc61fb42b8dd6",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "55dd4787cd47e9ccbf056ba67809fd45638b1e3f21889c6a31ce192ce87b8e67"
},
"routes": [
"/proxmox/backups"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Coverage workspace, mock fleet of 68 targets: Backup health strip footnote explains attention, unprotected and unknown with the 7-day window from the server policy",
"Coverage table at 1440: Workload / Type / Target ID / Node / Posture / Last backup / Task, no PBS snapshot, PVE file or Guest snapshot columns",
"Coverage table at 390: Workload / Posture / Age / Job, footnote wraps under the legend without a reserved column",
"Row expansion for a Needs attention guest with a failed newer job: posture explanation, Provider evidence and Restore evidence listing PVE file and Snapshot sources",
"No console errors on either viewport"
],
"interactions": [
"Expanded and collapsed coverage rows to inspect provider and restore evidence",
"Compared before/after screenshots at both viewports"
]
}
@@ -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}
@@ -24,6 +24,9 @@ export interface CoverageStripProps {
segments: readonly CoverageStripSegment[];
// Compact context shown to the right of the title (e.g. "1.2 TB on disk").
tail?: JSX.Element;
// The rule behind the segments, so a count such as "17 attention" says what
// it is counting.
footnote?: JSX.Element;
}
export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props) => {
@@ -80,6 +83,11 @@ export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props
)}
</For>
</ul>
<Show when={props.footnote}>
<p class="mt-1.5 text-[11px] leading-4 text-muted" data-coverage-strip-footnote>
{props.footnote}
</p>
</Show>
</div>
);
};
@@ -58,6 +58,7 @@ import {
type RecoverableFilterValue,
type RecoverableSortKey,
} from './proxmoxBackupsTableModel';
import { getBackupPostureRuleText } from './proxmoxBackupsTablePresentation';
import {
COVERAGE_FILTERS,
RECOVERABLE_FILTERS,
@@ -364,20 +365,8 @@ export const ProxmoxBackupsTable: Component<{
];
});
// Source columns auto-hide when no workload anywhere has that data (computed
// over the full set so columns don't flicker as filters change). A PBS-only
// fleet drops the Archive and Snapshot columns.
const coverageHasPbs = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestPBS) || row.pbsCount > 0),
);
const coverageHasArchive = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestArchive) || row.archiveCount > 0),
);
const coverageHasSnapshot = createMemo(() =>
recoveryModel().coverageRows.some(
(row) => Boolean(row.latestSnapshot) || row.snapshotCount > 0,
),
);
// The task column auto-hides when no workload has task history (computed
// over the full set so it does not flicker as filters change).
const coverageHasTask = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestTask)),
);
@@ -420,12 +409,6 @@ export const ProxmoxBackupsTable: Component<{
return cmpString(a.workload.label, b.workload.label, direction);
case 'latest':
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
case 'pbs':
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
case 'archive':
return cmpNumber(a.latestArchive?.createdMs, b.latestArchive?.createdMs, direction);
case 'snapshot':
return cmpNumber(a.latestSnapshot?.createdMs, b.latestSnapshot?.createdMs, direction);
case 'task':
return cmpNumber(a.latestTask?.startedMs, b.latestTask?.startedMs, direction);
}
@@ -735,6 +718,7 @@ export const ProxmoxBackupsTable: Component<{
{/* The health strip renders identically in both route-backed views. */}
<ProxmoxBackupsCoverageStrip
title="Backup health"
footnote={getBackupPostureRuleText(protectionPostures.policy()) ?? undefined}
tail={
<span>
{liveTotalCount()} targets · {recoveryModel().coverageSummary.recoverableArtifacts}{' '}
@@ -925,9 +909,6 @@ export const ProxmoxBackupsTable: Component<{
onSort={handleCoverageSort}
expandedKeys={expandedCoverageRows()}
onToggleExpand={toggleCoverageExpansion}
showPbsColumn={coverageHasPbs()}
showArchiveColumn={coverageHasArchive()}
showSnapshotColumn={coverageHasSnapshot()}
showTaskColumn={coverageHasTask()}
/>
@@ -964,9 +945,6 @@ export const ProxmoxBackupsTable: Component<{
onSort={handleCoverageSort}
expandedKeys={expandedCoverageRows()}
onToggleExpand={toggleCoverageExpansion}
showPbsColumn={coverageHasPbs()}
showArchiveColumn={coverageHasArchive()}
showSnapshotColumn={coverageHasSnapshot()}
showTaskColumn={coverageHasTask()}
/>
</div>
@@ -27,7 +27,6 @@ import {
type WorkloadCoverageRow,
} from './proxmoxBackupRecoveryModel';
import type { CoverageSortKey } from './proxmoxBackupsTableModel';
import { getProxmoxBackupSourcePresentation } from './proxmoxBackupSourcePresentation';
import {
ArtifactSourceBadge,
ArtifactStateBadge,
@@ -42,6 +41,7 @@ import {
getCoverageColumnWidthStyle,
getCoverageLayoutForContainer,
isCoverageEvidenceColumnVisible,
selectCoverageRestoreEvidence,
type CoverageColumnId,
} from './proxmoxBackupsTablePresentation';
import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing';
@@ -155,12 +155,8 @@ export function ProxmoxCoverageTable(props: {
onSort: (key: CoverageSortKey) => void;
expandedKeys: ReadonlySet<string>;
onToggleExpand: (key: string) => void;
// Source columns auto-hide when no workload anywhere has that data (e.g. a
// PBS-only fleet drops the PVE files and Snapshots columns), matching how the
// source-detail tables already drop their conditional columns.
showPbsColumn: boolean;
showArchiveColumn: boolean;
showSnapshotColumn: boolean;
// The task column auto-hides when no workload has task history. Per-source
// ages (PBS snapshot, PVE file, guest snapshot) live in the row expansion.
showTaskColumn: boolean;
layoutWidth?: Accessor<number | null | undefined>;
}) {
@@ -171,9 +167,6 @@ export function ProxmoxCoverageTable(props: {
});
const visibleColumns = createMemo(() =>
getCoverageColumns(layoutMode(), {
pbs: props.showPbsColumn,
archive: props.showArchiveColumn,
snapshot: props.showSnapshotColumn,
task: props.showTaskColumn,
}),
);
@@ -184,9 +177,6 @@ export function ProxmoxCoverageTable(props: {
// ages were short enough for the usual gutters; compact ages use that space.
const ageCellClass = (kind: 'text' | 'numeric-value') =>
`${layoutMode() === 'compact' ? `px-0.5 py-1 ${kind === 'numeric-value' ? 'text-right' : ''}` : getPlatformTableCellClassForKind(kind)} text-base-content`;
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
const archiveSource = getProxmoxBackupSourcePresentation('archive');
const snapshotSource = getProxmoxBackupSourcePresentation('snapshot');
// Polling rebuilds recovery-model objects. Preserve logical row identity so
// Solid does not replace the focused toggle (or the browser's scroll anchor).
const [stableRows, setStableRows] = createStore<WorkloadCoverageRow[]>([]);
@@ -279,47 +269,6 @@ export function ProxmoxCoverageTable(props: {
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<Show when={columnVisible('pbs')}>
<SortableHead
label={
layoutMode() === 'compact' || layoutMode() === 'expanded'
? 'PBS'
: 'PBS snapshot'
}
sortKey="pbs"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('archive')}>
<SortableHead
label={layoutMode() === 'compact' ? 'PVE' : 'PVE file'}
sortKey="archive"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('snapshot')}>
<SortableHead
label={
layoutMode() === 'compact' || layoutMode() === 'expanded'
? 'Guest'
: 'Guest snapshot'
}
sortKey="snapshot"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('task')}>
<SortableHead
label={layoutMode() === 'compact' ? 'Job' : 'Task'}
@@ -347,10 +296,7 @@ export function ProxmoxCoverageTable(props: {
<For each={tableWindow.visibleItems()}>
{(row) => {
const isExpanded = () => props.expandedKeys.has(row.key);
const evidence = () =>
[...row.artifacts]
.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0))
.slice(0, 8);
const evidence = () => selectCoverageRestoreEvidence(row.artifacts);
const detailRowId = () => `proxmox-coverage-evidence-${row.key}`;
return (
<>
@@ -457,59 +403,6 @@ export function ProxmoxCoverageTable(props: {
)}
</Show>
</TableCell>
<Show when={columnVisible('pbs')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestPBS}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.pbs}
compact={layoutMode() === 'compact'}
emptyTitle={pbsSource.coverageFallbackLabel}
/>
}
>
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('archive')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestArchive}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.archive}
compact={layoutMode() === 'compact'}
emptyTitle={archiveSource.coverageFallbackLabel}
/>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('snapshot')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestSnapshot}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.snapshot}
compact={layoutMode() === 'compact'}
emptyTitle={snapshotSource.coverageFallbackLabel}
/>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('task')}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -66,9 +66,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={true}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -81,8 +78,9 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(headers).toContain('Node');
expect(headers).toContain('Posture▲');
expect(headers).toContain('Last backup');
expect(headers).toContain('PBS snapshot');
expect(headers).toContain('Guest snapshot');
// Per-source ages are expansion detail at every width.
expect(headers).not.toContain('PBS snapshot');
expect(headers).not.toContain('Guest snapshot');
expect(headers).not.toContain('PVE file');
expect(headers).not.toContain('Task');
// Identity data lives in dedicated cells, not stacked under the name.
@@ -93,7 +91,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(document.body.textContent).not.toContain('Node pve1');
});
it('keeps posture and provider evidence visible in compact rows', () => {
it('keeps posture, backup age and job visible in compact rows', () => {
render(() => (
<ProxmoxCoverageTable
rows={[row]}
@@ -106,15 +104,12 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={true}
showSnapshotColumn={true}
showTaskColumn={true}
layoutWidth={() => 330}
/>
));
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'PBS', 'Job']);
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'Job']);
expect(document.body.textContent).toContain('Prot.');
expect(document.body.textContent).not.toContain('VM 100 · pve1');
});
@@ -132,9 +127,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -158,9 +150,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>(['w1'])}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -197,9 +186,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -227,24 +213,15 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={true}
showSnapshotColumn={true}
showTaskColumn={true}
layoutWidth={() => 1_200}
/>
));
// The header already names the source, so the cell does not repeat it and
// cannot truncate to "No PVE backup f…".
// The cell says None and the full reason sits on hover, so it cannot
// truncate to "No PBS snapshot or PVE b…".
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
for (const title of [
'No PBS snapshot or PVE backup file',
'No PBS snapshot',
'No PVE backup file',
'No guest snapshot',
'No recent task',
]) {
for (const title of ['No PBS snapshot or PVE backup file', 'No recent task']) {
expect(empty(title)).toHaveTextContent(/^None$/);
}
});
@@ -4,6 +4,7 @@ import {
getBackupServerColumns,
getBackupServerColumnWidthStyle,
getBackupServerLayoutForContainer,
getBackupPostureRuleText,
getCoverageColumns,
getCoverageColumnWidthStyle,
getCoverageLayoutForContainer,
@@ -11,6 +12,7 @@ import {
getRecoverableColumnWidthStyle,
getRecoverableLayoutForContainer,
isCoverageEvidenceColumnVisible,
selectCoverageRestoreEvidence,
} from '../proxmoxBackupsTablePresentation';
const ids = <T extends string>(columns: readonly { id: T }[]) => columns.map((column) => column.id);
@@ -69,24 +71,23 @@ describe('Proxmox backups responsive table presentation', () => {
});
it('prioritizes workload posture, restore freshness, and task failures in coverage', () => {
const everySource = { pbs: true, archive: true, snapshot: true, task: true };
expect(ids(getCoverageColumns('compact', everySource))).toEqual([
const withTasks = { task: true };
expect(ids(getCoverageColumns('compact', withTasks))).toEqual([
'workload',
'posture',
'latest',
'pbs',
'task',
]);
expect(
getCoverageColumnWidthStyle(
'workload',
'compact',
ids(getCoverageColumns('compact', everySource)),
ids(getCoverageColumns('compact', withTasks)),
),
).toEqual({
width: '40%',
});
expect(ids(getCoverageColumns('operational', everySource))).toEqual([
expect(ids(getCoverageColumns('operational', withTasks))).toEqual([
'workload',
'type',
'node',
@@ -94,31 +95,98 @@ describe('Proxmox backups responsive table presentation', () => {
'latest',
'task',
]);
expect(ids(getCoverageColumns('expanded', everySource))).toEqual([
// Per-source ages (PBS snapshot, PVE file, guest snapshot) are expansion
// detail at every width; the widest layout only adds the target id.
expect(ids(getCoverageColumns('expanded', withTasks))).toEqual(
ids(getCoverageColumns('operational', withTasks)),
);
expect(ids(getCoverageColumns('full', withTasks))).toEqual([
'workload',
'type',
'targetId',
'node',
'posture',
'latest',
'pbs',
'archive',
'snapshot',
'task',
]);
expect(ids(getCoverageColumns('full', everySource))).toHaveLength(10);
});
it('does not reserve empty optional coverage columns', () => {
expect(
ids(
getCoverageColumns('full', {
pbs: true,
archive: false,
snapshot: false,
task: true,
}),
),
).toEqual(['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'pbs', 'task']);
it('does not reserve an empty task column', () => {
expect(ids(getCoverageColumns('full', { task: false }))).toEqual([
'workload',
'type',
'targetId',
'node',
'posture',
'latest',
]);
});
it('states the backup health rule from the server posture policy', () => {
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 7 * 86_400 })).toBe(
'Attention: the newest backup is older than 7 days, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 86_400 })).toContain(
'older than 1 day,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 3_600 })).toContain(
'older than 1 hour,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 36 * 3_600 })).toContain(
'older than 36 hours,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 0 })).toBeNull();
expect(getBackupPostureRuleText(null)).toBeNull();
});
it('keeps the newest restore point of every source in the coverage expansion', () => {
const snapshots = Array.from({ length: 10 }, (_, index) => ({
id: `snap-${index}`,
sourceKind: 'snapshot',
createdMs: 1_000 + index,
}));
const artifacts = [
{ id: 'pbs-old', sourceKind: 'pbs', createdMs: 10 },
{ id: 'pbs-new', sourceKind: 'pbs', createdMs: 500 },
{ id: 'archive', sourceKind: 'archive', createdMs: 400 },
...snapshots,
];
const evidence = selectCoverageRestoreEvidence(artifacts);
expect(evidence).toHaveLength(8);
expect(evidence.map((artifact) => artifact.id)).toEqual([
'snap-9',
'snap-8',
'snap-7',
'snap-6',
'snap-5',
'snap-4',
'pbs-new',
'archive',
]);
expect(selectCoverageRestoreEvidence(artifacts.slice(0, 3))).toHaveLength(3);
});
it('keeps the newest completed backup when a newer job is running or failed', () => {
const snapshots = Array.from({ length: 10 }, (_, index) => ({
id: `snap-${index}`,
sourceKind: 'snapshot',
createdMs: 1_000 + index,
}));
const artifacts = [
{ id: 'pbs-done', sourceKind: 'pbs', createdMs: 300 },
{ id: 'pbs-failed', sourceKind: 'pbs', createdMs: 600, failed: true },
{ id: 'pbs-running', sourceKind: 'pbs', createdMs: 700, running: true },
...snapshots,
];
const ids = selectCoverageRestoreEvidence(artifacts).map((artifact) => artifact.id);
expect(ids).toHaveLength(8);
expect(ids).toContain('pbs-running');
expect(ids).toContain('pbs-done');
expect(ids).not.toContain('pbs-failed');
});
it('keeps the recovery-point answer visible before verbose metadata', () => {
@@ -153,15 +153,11 @@ export function cmpBool(a: boolean, b: boolean, direction: 'asc' | 'desc'): numb
// sysadmin-default question on a backup page. String columns default to
// `asc` (A→Z). Boolean columns default to `desc` so "true" sorts first.
export type CoverageSortKey =
'posture' | 'workload' | 'latest' | 'pbs' | 'archive' | 'snapshot' | 'task';
export type CoverageSortKey = 'posture' | 'workload' | 'latest' | 'task';
export const COVERAGE_SORT_DEFAULT_DIRECTION: Record<CoverageSortKey, 'asc' | 'desc'> = {
posture: 'asc',
workload: 'asc',
latest: 'desc',
pbs: 'desc',
archive: 'desc',
snapshot: 'desc',
task: 'desc',
};
@@ -23,16 +23,7 @@ export type BackupServerColumnId =
| 'dedup';
export type CoverageColumnId =
| 'workload'
| 'type'
| 'targetId'
| 'node'
| 'posture'
| 'latest'
| 'pbs'
| 'archive'
| 'snapshot'
| 'task';
'workload' | 'type' | 'targetId' | 'node' | 'posture' | 'latest' | 'task';
export type RecoverableColumnId =
| 'workload'
@@ -182,66 +173,48 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
{ id: 'node', label: 'Node', kind: 'text' },
{ id: 'posture', label: 'Posture', kind: 'text' },
{ id: 'latest', label: 'Last backup', kind: 'numeric-value' },
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
{ id: 'archive', label: 'PVE file', kind: 'text' },
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
{ id: 'task', label: 'Task', kind: 'text' },
];
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
// On narrow surfaces answer: which workload, what posture, how recent is the
// newest independent backup, and did the latest task succeed? Provider-by-provider
// evidence is progressive detail in the expansion row; target identity folds
// beneath the name so the scan stays legible at 320px.
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
// Every layout answers: which workload, what posture, how recent is the
// newest independent backup, and did the latest task succeed? The
// provider-by-provider ages (PBS snapshot, PVE file, guest snapshot) are
// progressive detail in the expansion row at every width: side by side they
// made a stale backup look fresh beside a recent guest snapshot, which does
// not count as a backup. Target identity folds beneath the name on phones.
compact: ['workload', 'posture', 'latest', 'task'],
basic: ['workload', 'node', 'posture', 'latest', 'task'],
operational: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'pbs', 'archive', 'snapshot', 'task'],
full: COVERAGE_COLUMNS.map((column) => column.id),
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
full: ['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'task'],
};
const COVERAGE_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial<Record<CoverageColumnId, number>>
> = {
// Posture keeps its whole compact word ("Unknown"), the two age cells hold
// the suffix-free compact age, and the job dot takes what a "Job" header
// needs.
compact: { workload: 40, posture: 20, latest: 15, pbs: 15, task: 10 },
// Posture keeps its whole compact word ("Unknown"), the age cell holds the
// suffix-free compact age, and the job dot takes what a "Job" header needs.
compact: { workload: 46, posture: 22, latest: 18, task: 14 },
// Above the phone projection the identity columns (workload, node) take the
// slack that the short type badge, target id, and age cells cannot use, so
// names stay whole instead of truncating beside empty space.
basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
expanded: {
workload: 19,
type: 6.5,
node: 13.5,
posture: 13.5,
latest: 9.5,
pbs: 9.5,
archive: 10,
snapshot: 9.5,
task: 9,
},
expanded: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
full: {
workload: 17,
type: 5.5,
targetId: 8,
node: 11.5,
posture: 11,
latest: 8.5,
pbs: 10.5,
archive: 8.5,
snapshot: 12,
task: 7.5,
workload: 24,
type: 7,
targetId: 9,
node: 16,
posture: 16,
latest: 13,
task: 15,
},
};
export interface CoverageSourceVisibility {
pbs: boolean;
archive: boolean;
snapshot: boolean;
task: boolean;
}
@@ -252,9 +225,6 @@ export const getCoverageColumns = (
const layoutColumns = new Set(COVERAGE_VISIBLE[layout]);
return COVERAGE_COLUMNS.filter((column) => {
if (!layoutColumns.has(column.id)) return false;
if (column.id === 'pbs') return sourceVisibility.pbs;
if (column.id === 'archive') return sourceVisibility.archive;
if (column.id === 'snapshot') return sourceVisibility.snapshot;
if (column.id === 'task') return sourceVisibility.task;
return true;
});
@@ -385,3 +355,67 @@ export const isCoverageEvidenceColumnVisible = (
return layout === 'operational' || layout === 'expanded' || layout === 'full';
return layout === 'expanded' || layout === 'full';
};
// What the backup health counts mean, stated from the server's own posture
// policy so "17 attention" is never a number without a rule. The causes mirror
// internal/recovery/posture.go. Null until the policy has loaded; the strip
// then shows no rule rather than a guessed one.
export const getBackupPostureRuleText = (
policy: { freshnessWindowSeconds: number } | null | undefined,
): string | null => {
const seconds = policy?.freshnessWindowSeconds ?? 0;
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return `Attention: the newest backup is older than ${formatPostureWindow(seconds)}, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.`;
};
const formatPostureWindow = (seconds: number): string => {
if (seconds >= 86_400 && seconds % 86_400 === 0) {
const days = seconds / 86_400;
return days === 1 ? '1 day' : `${days} days`;
}
const hours = Math.max(1, Math.round(seconds / 3_600));
return hours === 1 ? '1 hour' : `${hours} hours`;
};
const COVERAGE_RESTORE_EVIDENCE_LIMIT = 8;
// Newest restore points for a coverage row's expansion. The newest point and
// the newest completed point of every source always make the cut, so neither a
// burst of recent guest snapshots nor a running or failed job can hide the
// latest usable PBS or PVE backup now that the table carries no per-source
// age columns.
export const selectCoverageRestoreEvidence = <
T extends {
id: string;
sourceKind: string;
createdMs?: number;
running?: boolean;
failed?: boolean;
},
>(
artifacts: readonly T[],
limit = COVERAGE_RESTORE_EVIDENCE_LIMIT,
): T[] => {
const newestFirst = [...artifacts].sort(
(left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0),
);
const picked = new Set<string>();
const newestSeen = new Set<string>();
const completedSeen = new Set<string>();
for (const artifact of newestFirst) {
if (!newestSeen.has(artifact.sourceKind)) {
newestSeen.add(artifact.sourceKind);
picked.add(artifact.id);
}
if (!artifact.running && !artifact.failed && !completedSeen.has(artifact.sourceKind)) {
completedSeen.add(artifact.sourceKind);
picked.add(artifact.id);
}
}
const cap = Math.max(limit, picked.size);
for (const artifact of newestFirst) {
if (picked.size >= cap) break;
picked.add(artifact.id);
}
return newestFirst.filter((artifact) => picked.has(artifact.id));
};
@@ -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`,
];
}