mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge remote-tracking branch 'origin/main' into feat/docker-silent-hosts
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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1d4c3aeb6e529af3b63992ef22152e6b8b8a33d8",
|
||||
"verified_at": "2026-10-05T01:34:30Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestDisksSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPMGSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx",
|
||||
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx",
|
||||
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx": "0a993dded673dbd2eb0b3919af2586a0fe4579ce10465e495710f526cf7ca1bd",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentDisksSection.tsx": "d54ba2278c35ca5748cc0a310bb738dd9128454b1283d547bed7938568f92221",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "3b5cb92fb9b684d5655c6453b7e2738fb776d4c91bd566c319cc6d135301e095",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx": "463bba443bc9dfc52e9cfcb83505640d5f8e91f5c731a8ec8cab1542f43e81a4",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx": "3ee73f2b61ed5da9532dce15637e9803c4465f7a70241edf16e1692439c55fa5",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx": "6226defd1e8adba0d28bae0998abd832993fc1e6b5b63f80024798c92684ecbd",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx": "7e793356d6488169f8307ada64db35d9c15c9eacf1bddd30144e193f53345051",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx": "8b89d2d4e3ffcbfe1dceb6d4a5d309c1d424d1d58059110e51b9e73a93fcd670",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestDisksSection.tsx": "7849b5ed2d8c2674915aa98bcbd4bf348881efc155f00dbcbe9febeabcc38458",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx": "6f1694090e0022d1fff0653b06d1cf136f5fcebd59c88c41a4f703125d555ec2",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx": "c48d40917928e0ca8564d4eb90cb0e6987c8c9313494a444fa98209a3eee5dc1",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx": "d6801951e3d0ec6eaa7b267ff68e0f13ba3838be9c697b58822b94c57d719f06",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPMGSection.tsx": "2606881fdc7a68b747c34793330756b41513c89f07cbebcc5a3f69c6039d6610",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx": "3a1bfafe2b44c724a5b351d37d721bf5992db31d66e761a0b782ad6f0a57cc7c",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxStorageSection.tsx": "c4da879c91150d4c8d32c2068a4a0988ac3934f9d7297bff45d63b2ee1f4f6e2",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx": "2d78009148e366e1546d52127ea1b0d0ee372037ca9b627a0ff3605854a1ca53",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx": "a628c3a17f16cb3ba74ca15bf53c0f8afa6d10390386b5b42f932c60105e167e",
|
||||
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx": "ea46c8920f008f1904fe6e51dbfc4c5fa755e5af92341fe223fe75428efdf077",
|
||||
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts": "75c2c8d6e0728b0ecec30b02c92185af63a32f03c43cede6a7068cba29943112"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/thresholds/proxmox",
|
||||
"/alerts/thresholds/docker",
|
||||
"/alerts/thresholds/kubernetes",
|
||||
"/alerts/thresholds/truenas",
|
||||
"/alerts/thresholds/vmware",
|
||||
"/alerts/thresholds/systems"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Proxmox tab: every collapsed group states its default limits (Virtualization Hosts CPU 80% \u00b7 Memory 85% \u00b7 Disk 90% \u00b7 Temp 80\u00b0C, VMs & Containers, Guest Filesystems, Storage Devices, PBS Servers, Mail Gateway with +12 more)",
|
||||
"Recovery and Snapshot Age groups disabled: Disabled badge and no defaults line",
|
||||
"CPU averaging collapsed to one line: 5-minute average for every resource",
|
||||
"Docker tab: Container Runtimes and Containers lead (Restart loop 3 in 5 min \u00b7 Memory limit 90% warn, 95% critical), Docker-only rules follow",
|
||||
"Kubernetes, TrueNAS, vSphere tabs: every group with defaults states them",
|
||||
"Machines tab: Machines and Machine Disks lead with defaults, SMART alert rules card follows",
|
||||
"Phone 390: defaults lines and CPU averaging summary wrap between items without breaking a value",
|
||||
"No console errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened CPU averaging disclosure: ten profile selects shown",
|
||||
"Changed Virtual machines CPU averaging to 15-minute: summary updated to 1 platform override and the unsaved-changes banner appeared (not saved)",
|
||||
"Expanded VMs & Containers to compare header defaults with the Global Defaults row"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Used to organize resource groups in the thresholds page.
|
||||
*/
|
||||
|
||||
import { Component, Show, createSignal, createEffect, JSX } from 'solid-js';
|
||||
import { Component, For, Show, createSignal, createEffect, JSX } from 'solid-js';
|
||||
import ChevronRight from 'lucide-solid/icons/chevron-right';
|
||||
import ChevronDown from 'lucide-solid/icons/chevron-down';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
@@ -34,6 +34,12 @@ export interface CollapsibleSectionProps {
|
||||
icon?: JSX.Element;
|
||||
/** Subtitle or description */
|
||||
subtitle?: string;
|
||||
/**
|
||||
* What the group alerts on by default, shown under the title so the page
|
||||
* answers "what will alert me" before the group is opened. Hidden while the
|
||||
* group is disabled, where it would promise alerts that cannot fire.
|
||||
*/
|
||||
defaultsSummary?: string;
|
||||
/** Message to show when section is empty */
|
||||
emptyMessage?: string;
|
||||
/** Whether to show a visual indicator for global disable state */
|
||||
@@ -133,6 +139,23 @@ export const CollapsibleSection: Component<CollapsibleSectionProps> = (props) =>
|
||||
<Show when={props.subtitle}>
|
||||
<span class="block truncate text-sm font-normal text-muted">{props.subtitle}</span>
|
||||
</Show>
|
||||
<Show when={!props.isGloballyDisabled && props.defaultsSummary}>
|
||||
<span
|
||||
class="block text-xs font-normal tabular-nums text-muted sm:truncate sm:text-sm"
|
||||
title={props.defaultsSummary}
|
||||
data-thresholds-defaults-summary
|
||||
>
|
||||
{/* Break between items only, never inside "Disk 90%". */}
|
||||
<For each={props.defaultsSummary?.split(' · ') ?? []}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
{index() > 0 ? ' · ' : ''}
|
||||
<span class="whitespace-nowrap">{item}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
+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}
|
||||
|
||||
@@ -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