mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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>
|
||||
|
||||
+23
@@ -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$/);
|
||||
}
|
||||
});
|
||||
|
||||
+89
-21
@@ -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`,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user