From aac5ca5fc2b3b43ff8b1118c52c2b7196549e393 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:35:28 +0100 Subject: [PATCH] Colour pulse-relaxed guests by the thresholds their alerts use The pulse-relaxed Proxmox tag lifts a guest's CPU, memory and disk alert triggers to a 95/92/95 floor, but the browser coloured bars from the configured thresholds alone. A relaxed guest at 88% memory showed a red bar while the alert engine stayed silent until 92%, and its Alerts thresholds row kept showing 80/85/90 with no sign that the tag raised them (issue #863, comment of 2026-01-16). - resolveMetricDisplayThresholds takes the tags the engine reads for a guest and applies the backend's applyRelaxedGuestThresholds step: unset gets the floor, a lower trigger is raised and keeps its clear point unless the raised trigger reaches it, and Off stays Off. - getWorkloadAlertPolicyTags passes tags only for Proxmox VMs and LXCs, the only guests whose tags reach alert policy. vSphere and TrueNAS VMs and containers keep their thresholds whatever they are tagged. - A relaxed guest's Alerts thresholds row and phone card add one line naming the tag and the thresholds it raises. It follows unsaved edits, while the cells keep the configured values the editor changes. --- .../v6/internal/subsystems/alerts.md | 28 +++ .../subsystems/frontend-primitives.md | 7 + .../subsystems/performance-and-scalability.md | 4 + .../1f9dcc1c17da6345.json | 73 ++++++++ .../Alerts/AlertResourceRelaxedTriggers.tsx | 31 ++++ .../Alerts/AlertResourceTableMobile.tsx | 16 ++ .../Alerts/AlertResourceTableRow.tsx | 15 ++ .../components/Alerts/ResourceTable.test.tsx | 76 ++++++++ .../Alerts/alertResourceTableModel.ts | 49 ++++++ .../Workloads/__tests__/GuestRow.test.tsx | 2 + .../__tests__/workloadTopology.test.ts | 51 ++++++ .../Workloads/useGuestDrawerState.ts | 3 + .../components/Workloads/useGuestRowState.ts | 3 + .../components/Workloads/workloadTopology.ts | 19 ++ .../hooks/useThresholdsGuestData.ts | 7 + .../features/alerts/thresholds/tableTypes.ts | 2 + .../src/stores/alertsActivation.ts | 3 +- .../utils/__tests__/metricThresholds.test.ts | 165 +++++++++++++++++- frontend-modern/src/utils/metricThresholds.ts | 87 ++++++++- 19 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/browser-verification/1f9dcc1c17da6345.json create mode 100644 frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 513b44c27d..01e02c4cc5 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1609,6 +1609,34 @@ resolve overrides through the shared candidate set `buildProjectedOverrides` indexes platform alert rows under). Static metric-color defaults are only fallback presentation behavior for callers that do not have alert configuration in scope. +Guest bars follow `pulse-relaxed` the same way. `resolveMetricDisplayThresholds` +takes the tags the engine reads for a guest-scope resource and, when they +include `pulse-relaxed`, applies the `applyRelaxedGuestThresholds` step to +CPU, memory and disk before mapping to display thresholds: an unset threshold +gets the 95/92/95 floor with a clear five below it, a lower trigger is raised +to the floor and keeps its own clear point (a missing one filled five below, +as `ensureHysteresisThreshold` does) unless the raised trigger reaches it, and +an Off threshold stays Off. A relaxed guest's bar therefore turns critical +where its alert fires; 88% memory reads warning, not critical, under the +relaxed 92% trigger. The display resolver does not read filter-driven custom +rules, which the engine folds in before the override, so a guest a custom +rule matches can still colour by thresholds the engine does not use, relaxed +or not. Only Proxmox VMs and LXCs carry their tags into alert +policy, so `getWorkloadAlertPolicyTags` in +`frontend-modern/src/components/Workloads/workloadTopology.ts` passes tags for +those guests alone, and a vSphere VM or a container keeps its thresholds +whatever it is tagged. The Alerts thresholds row for a relaxed Proxmox guest +keeps showing the configured values its editor changes and adds one line +naming the tag and the thresholds it raises +(`getAlertResourceRelaxedTriggerSummary` in +`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, fed by +`pulseRelaxed` from `useThresholdsGuestData`); before it, the tag overrode +those settings with no visible sign in the UI (issue #863). Proof: the +pulse-relaxed cases in `frontend-modern/src/utils/__tests__/metricThresholds.test.ts`, +the alert policy tag cases in +`frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts`, +and the pulse-relaxed guest cases in +`frontend-modern/src/components/Alerts/ResourceTable.test.tsx`. Docker container image-update alerts are lifecycle-governed by the alerts runtime. Disabling Docker update alerts globally, disabling alerts for a diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 18c2631347..c46f25a9ec 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5680,6 +5680,13 @@ identity candidates) and pass them into the shared metric bar primitives (`ResponsiveMetricCell`, `StackedMemoryBar`, `StackedDiskBar`); the static `METRIC_THRESHOLDS` display constants remain fallback-only presentation for callers without alert configuration in scope. +Workload guest bars pass the guest's alert policy tags +(`getWorkloadAlertPolicyTags`) through that same `getMetricThresholds` call, +so a Proxmox guest tagged `pulse-relaxed` colours CPU, memory and disk by the +relaxed floor the engine applies to its configured thresholds; bar +primitives never read tags themselves. The Alerts thresholds table's shared row and mobile card render +the relaxed-trigger line from `getAlertResourceRelaxedTriggerSummary`, not +from per-section copy. The vSphere ESXi host table keeps power state distinct from aggregate resource health without spending a phone column on the repeated normal case. At phone widths the Power column is hidden and a shared `MetadataBadge` appears beside diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 26a57d60c5..d0c59c38e4 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1125,6 +1125,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. 8. Render workload row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract 9. Format infrastructure sensor labels through the shared `frontend-modern/src/utils/textPresentation.ts` presentation helper instead of maintaining a local title-casing implementation in `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` 10. Extend workload row contract and per-row hot-path derivations through `frontend-modern/src/components/Workloads/guestRowModel.tsx` and `frontend-modern/src/components/Workloads/useGuestRowState.ts`, and extend tooltip-backed row cell presentation through `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, rather than rebuilding column metadata, row identity, cell tooltips, or anomaly correlation inside `frontend-modern/src/components/Workloads/GuestRow.tsx` + Guest metric thresholds read the guest's alert policy tags through one + `getWorkloadAlertPolicyTags` memo per row and per drawer, beside the + existing scope and override-candidate memos, so `pulse-relaxed` bar + colouring adds no per-render tag parsing and no extra store reads. On coarse-pointer touch layouts, those cell details must not spawn floating hover tooltips from synthesized mouse events; a row tap remains the primary drawer action. Tooltip suppression belongs to the shared hover-capability diff --git a/frontend-modern/browser-verification/1f9dcc1c17da6345.json b/frontend-modern/browser-verification/1f9dcc1c17da6345.json new file mode 100644 index 0000000000..17bc045230 --- /dev/null +++ b/frontend-modern/browser-verification/1f9dcc1c17da6345.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "base_sha": "1a094646d7f21217eda051af6ba0252e208f283e", + "verified_at": "2026-10-07T22:33:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx", + "frontend-modern/src/components/Alerts/alertResourceTableModel.ts", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts", + "frontend-modern/src/components/Workloads/useGuestRowState.ts", + "frontend-modern/src/components/Workloads/workloadTopology.ts", + "frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts", + "frontend-modern/src/features/alerts/thresholds/tableTypes.ts", + "frontend-modern/src/stores/alertsActivation.ts", + "frontend-modern/src/utils/metricThresholds.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx": "761c794a8a5a6ccaac10967e0d9dc54e5f1bc798c6915cd3d72b60abed22fe0b", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "7804d039237b8149aa3c1de49b2f28a1b7efd9d5c84efdf2b305cc67c5c2bc6f", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx": "2f1f42acaf25f25b754d07127e39d65dd9760f6be82216706a4d43f236867a9c", + "frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "c36b7bede475ccb1b9fa9f8a293d9008f45cea103c5b9fe214cc0dee7bb55e70", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "80be72488a4828823b24013b960e081d92ced5ade3015084c933b10b7f133f6a", + "frontend-modern/src/components/Workloads/useGuestRowState.ts": "6816bb90d9abc48ddad0999aea0f471882f1640b63eb8ef7d9fae00a37d41904", + "frontend-modern/src/components/Workloads/workloadTopology.ts": "2ad72c28b21d38f0bff17ddafb963627d42dd5199fff9fa354014adea9ddd3c0", + "frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts": "bb6c362860337aa3162106226eeaeeca3404eb5936930af5701acf7969bbc9d6", + "frontend-modern/src/features/alerts/thresholds/tableTypes.ts": "cc8f8df70b75c6abe00af8938a826659b475be6e303d88fa233ede882add3ec1", + "frontend-modern/src/stores/alertsActivation.ts": "1a6b2fb1cf1afc3ed035c7f1801b5733250c6228e9f22d301839c24513830ceb", + "frontend-modern/src/utils/metricThresholds.ts": "38a1cd937b010afb9f42a801d89c04c281d5cb1d47c5dd9402cce9d1e882e40c" + }, + "routes": [ + "/proxmox", + "/alerts/thresholds/proxmox" + ], + "viewports": [ + { + "width": 2560, + "height": 1100 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 800, + "height": 780 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock-mode stack (backend :7867, Vite :5387) built from this branch with a dev-only -overlay tagging the ci-runner-01 and analytics-batch-01 demo profiles pulse-relaxed (68 relaxed Proxmox VMs); default guest thresholds CPU 80/75, memory 85/80, disk 90/85", + "Before the change on the same stack: relaxed ci-runner-117 at 88% memory drew a red (critical) memory bar while the engine raises no alert below 92%, and its Alerts thresholds row showed 80/85/90 with nothing naming the tag", + "Proxmox guests filtered by tags:pulse-relaxed: all 66 running relaxed rows coloured by the relaxed band (warning from 80%, critical from 92% memory); analytics-batch-70 at 87% memory reads warning (yellow) at 800px and at 375px", + "Control: untagged Proxmox guests in the same band stay critical (red): redis-cache-259 92%, redis-cache-379 91%, checkout-web-02 92%, postgres-primary-293 86%, at desktop and 375px", + "Alerts > Thresholds > Proxmox > VMs & Containers, analytics-batch-70: card layout (default page width, every viewport up to 2560px) and table layout (full-width mode at 2560px) both show \"Proxmox tag pulse-relaxed: alerts at CPU 95% \u00b7 Memory 92% \u00b7 Disk 95%\" under the name, with the explanatory title; the metric cells keep the configured 80% / 85% / 90%", + "At 375px the line sits at full card width under the card header in two lines; pulse-relaxed and each threshold stay unbroken (whitespace-nowrap spans); no horizontal page scroll (scrollWidth 375)", + "Untagged guest postgres-primary-293 on the thresholds page shows no relaxed line", + "No console errors from the final build; one transient ReferenceError came from an intermediate HMR state between two edits and did not recur after reload", + "After this pass the only change to these files was prettier wrapping the JSX attributes of AlertResourceRelaxedTriggers.tsx onto separate lines (no output change), plus a rebase onto main that touched none of the changed paths", + "After a review, relaxGuestThresholdValue now fills a legacy bare trigger number with the backend's fixed five-point clear instead of the configured hysteresis margin, matching ensureHysteresisThreshold. The current backend sends hysteresis objects, so this branch is not reachable from the stack exercised above; the rendered bars and lines checked here resolve through the unchanged object path, and the numeric branch is pinned by metricThresholds.test.ts at margins 5 and 10" + ], + "interactions": [ + "Searched the Proxmox guest list by name and by tags:pulse-relaxed and read each memory bar segment fill against its percentage", + "Expanded VMs & Containers on Alerts > Thresholds > Proxmox and searched for the relaxed and an untagged guest", + "Opened the row editor on analytics-batch-70, set Memory to 96 without saving: the line updated to \"alerts at CPU 95% \u00b7 Disk 95%\"; cancelled and it returned to all three", + "Switched full-width mode on to reach the table layout at 2560px, then restored it", + "Repeated the thresholds and Proxmox passes at 375x812" + ] +} diff --git a/frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx b/frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx new file mode 100644 index 0000000000..dcac54e4c2 --- /dev/null +++ b/frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx @@ -0,0 +1,31 @@ +import { For } from 'solid-js'; + +import type { AlertResourceRelaxedTriggerSummary } from './alertResourceTableModel'; + +/** + * One line under a pulse-relaxed guest's threshold row naming the tag and the + * thresholds it raises. The tag and each threshold never wrap mid-token, so a + * narrow card does not split "pulse-relaxed" at its hyphen. + */ +export function AlertResourceRelaxedTriggers(props: { + summary: AlertResourceRelaxedTriggerSummary; + class: string; +}) { + return ( +

+ Proxmox tag pulse-relaxed: alerts at{' '} + + {(threshold, index) => ( + <> + {index() > 0 ? ' · ' : ''} + {threshold} + + )} + +

+ ); +} diff --git a/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx b/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx index ab7bd0a7cd..efc5d13af2 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx @@ -30,6 +30,7 @@ import { getAlertResourceMetricBounds, getAlertResourceMetricDisplayValue, getAlertResourceMetricStep, + getAlertResourceRelaxedTriggerSummary, isAlertResourceMetricOff, isAlertResourceMetricOverridden, normalizeAlertResourceMetricKey, @@ -37,6 +38,7 @@ import { resolveAlertResourceMetricEnableValue, } from './alertResourceTableModel'; import type { Resource } from '@/features/alerts/thresholds/tableTypes'; +import { AlertResourceRelaxedTriggers } from './AlertResourceRelaxedTriggers'; import type { OfflineState, ResourceTableProps } from './ResourceTable'; interface AlertResourceTableMobileProps { @@ -368,6 +370,14 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { const displayValue = (metric: string) => getDisplayValue(resource, metric, isEditing()); const isOverridden = (metric: string) => isAlertResourceMetricOverridden(resource, metric); + const relaxedTriggerSummary = () => + props.table.globalDisableFlag?.() + ? null + : getAlertResourceRelaxedTriggerSummary( + resource, + props.table.editingThresholds(), + isEditing(), + ); return ( + + {(summary) => ( + + )} + + isAlertResourceMetricOverridden(props.resource, metric); + const relaxedTriggerSummary = () => + props.globalDisableFlag?.() + ? null + : getAlertResourceRelaxedTriggerSummary( + props.resource, + props.editingThresholds(), + isEditing(), + ); const preferredIntentSignal = (): AlertIntentSignal => { for (const metric of ['cpu', 'memory', 'disk'] as const) { if ( @@ -322,6 +332,11 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { onInput={(e) => props.setEditingNote(e.currentTarget.value)} /> + + {(summary) => ( + + )} +

{props.resource.note as string} diff --git a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx index ad29f3d84f..dba379a224 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx @@ -586,6 +586,82 @@ describe('ResourceTable', () => { }); }); + describe('pulse-relaxed guests', () => { + const relaxedGuest = (overrides: Partial = {}) => + makeResource({ + id: 'vm-1', + name: 'batch-vm', + type: 'guest', + pulseRelaxed: true, + thresholds: {}, + defaults: { cpu: 80, memory: 85, disk: 90 }, + ...overrides, + }); + const renderGrouped = (resource: Resource, overrides: Record = {}) => + render(() => ( + + )); + + it.each([ + ['desktop', false], + ['mobile', true], + ])('states the thresholds the tag raises on the %s row', (_layout, mobile) => { + mockIsMobile.mockReturnValue(mobile); + renderGrouped(relaxedGuest()); + + const line = screen.getByTestId('alert-resource-relaxed-triggers'); + expect(line).toHaveTextContent( + 'Proxmox tag pulse-relaxed: alerts at CPU 95% · Memory 92% · Disk 95%', + ); + expect(line.getAttribute('title')).toContain('Remove the tag in Proxmox'); + // The row keeps showing the configured values the editor changes. + expect(screen.getByText('80')).toBeInTheDocument(); + }); + + it('omits metrics the tag leaves alone', () => { + renderGrouped(relaxedGuest({ thresholds: { cpu: -1, memory: 96 } })); + + expect(screen.getByTestId('alert-resource-relaxed-triggers')).toHaveTextContent( + 'Proxmox tag pulse-relaxed: alerts at Disk 95%', + ); + }); + + it('follows unsaved edits', () => { + renderGrouped(relaxedGuest(), { + editingId: () => 'vm-1', + editingThresholds: () => ({ cpu: 97, memory: 93, disk: 70 }), + }); + + expect(screen.getByTestId('alert-resource-relaxed-triggers')).toHaveTextContent( + 'Proxmox tag pulse-relaxed: alerts at Disk 95%', + ); + }); + + it('stays hidden when the tag raises nothing or alerts are off', () => { + renderGrouped(relaxedGuest({ thresholds: { cpu: 99, memory: 99, disk: 99 } })); + expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull(); + cleanup(); + + renderGrouped(relaxedGuest({ disabled: true })); + expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull(); + cleanup(); + + renderGrouped(relaxedGuest(), { globalDisableFlag: () => true }); + expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull(); + cleanup(); + + renderGrouped(relaxedGuest({ pulseRelaxed: false })); + expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull(); + }); + }); + describe('metric display values', () => { it('shows formatted metric values using formatMetricValue', () => { const formatMetricValue = vi.fn((metric: string, value: number | undefined) => { diff --git a/frontend-modern/src/components/Alerts/alertResourceTableModel.ts b/frontend-modern/src/components/Alerts/alertResourceTableModel.ts index 99c7b5edb0..dd2ef9076b 100644 --- a/frontend-modern/src/components/Alerts/alertResourceTableModel.ts +++ b/frontend-modern/src/components/Alerts/alertResourceTableModel.ts @@ -1,6 +1,7 @@ import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource'; import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; +import { getPulseRelaxedGuestTrigger, type MetricType } from '@/utils/metricThresholds'; const COLUMN_TOOLTIP_LOOKUP: Record = { 'cpu %': 'Percent CPU utilization allowed before an alert fires.', @@ -430,6 +431,54 @@ export function getAlertResourceMetricDisplayValue( return fallback !== undefined ? fallback : 0; } +const RELAXED_TRIGGER_METRIC_LABELS: Record = { + cpu: 'CPU', + memory: 'Memory', + disk: 'Disk', +}; + +export interface AlertResourceRelaxedTriggerSummary { + /** Raised thresholds in metric order, e.g. "CPU 95%". */ + raised: string[]; + title: string; +} + +/** + * The alert thresholds a guest's pulse-relaxed tag raises above the values + * its row shows, as the alert engine applies them, or null when the tag + * raises none. Reads the values being edited, so the line follows an edit + * before it is saved. + */ +export function getAlertResourceRelaxedTriggerSummary( + resource: AlertResourceTableResourceLike, + editingThresholds?: AlertResourceThresholdMap, + isEditing = false, +): AlertResourceRelaxedTriggerSummary | null { + if (resource.pulseRelaxed !== true || resource.disabled === true) { + return null; + } + const rowThresholds = isEditing ? editingThresholds : resource.thresholds; + const raised = (['cpu', 'memory', 'disk'] as const).flatMap((metric) => { + const configured = + parseAlertMetricNumber(rowThresholds?.[metric]) ?? + parseAlertMetricNumber(resource.defaults?.[metric]); + const trigger = getPulseRelaxedGuestTrigger(metric, configured); + if (trigger === null || (configured !== undefined && trigger <= configured)) { + return []; + } + return [`${RELAXED_TRIGGER_METRIC_LABELS[metric]} ${trigger}%`]; + }); + if (raised.length === 0) { + return null; + } + return { + raised, + title: + 'This guest is tagged pulse-relaxed in Proxmox, which raises these alert thresholds ' + + 'above the values in this row. Remove the tag in Proxmox to alert at the row values.', + }; +} + export function isAlertResourceMetricOverridden( resource: AlertResourceTableResourceLike, metric: string, diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 384b5ce602..b6db727290 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -126,6 +126,8 @@ vi.mock('@/components/shared/workloadTypeBadges', () => ({ })); vi.mock('../workloadTopology', () => ({ + getWorkloadAlertPolicyTags: (guest: WorkloadGuest) => + Array.isArray(guest.tags) ? guest.tags : [], getWorkloadAlertResourceIdCandidates: (guest: WorkloadGuest) => [guest.id], getWorkloadAlertThresholdScope: (guest: WorkloadGuest) => guest.workloadType === 'app-container' ? 'docker' : 'guest', diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts index 410afb7f10..4f8d60fdcf 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts @@ -8,6 +8,7 @@ import { getWorkloadContainerHostId, getDiscoveryResourceIdForWorkload, getKubernetesContextKey, + getWorkloadAlertPolicyTags, getWorkloadDockerHostId, workloadNodeScopeId, } from '@/components/Workloads/workloadTopology'; @@ -239,4 +240,54 @@ describe('workloadTopology', () => { expect(getDiscoveryResourceIdForWorkload(vm)).toBe('103'); }); }); + + describe('alert policy tags', () => { + it('passes only Proxmox guest tags to the threshold resolver', () => { + const tags = ['batch', 'pulse-relaxed']; + const proxmoxVm = makeGuest(1, { + type: 'vm', + workloadType: 'vm', + platformType: 'proxmox-pve', + tags, + }); + const proxmoxLxc = makeGuest(2, { + type: 'lxc', + workloadType: 'system-container', + platformScopes: ['proxmox-pve', 'agent'], + tags, + }); + const legacyVm = makeGuest(3, { type: 'vm', workloadType: 'vm', tags }); + const vsphereVm = makeGuest(4, { + type: 'vm', + workloadType: 'vm', + platformType: 'vmware-vsphere', + tags, + }); + const appContainer = makeGuest(5, { + type: 'app-container', + workloadType: 'app-container', + platformType: 'docker', + tags, + }); + + expect(getWorkloadAlertPolicyTags(proxmoxVm)).toEqual(tags); + expect(getWorkloadAlertPolicyTags(proxmoxLxc)).toEqual(tags); + expect(getWorkloadAlertPolicyTags(legacyVm)).toEqual(tags); + // The alert engine never reads Pulse control tags from these. + expect(getWorkloadAlertPolicyTags(vsphereVm)).toEqual([]); + expect(getWorkloadAlertPolicyTags(appContainer)).toEqual([]); + }); + + it('splits a legacy raw tag string', () => { + const guest = makeGuest(6, { + type: 'vm', + workloadType: 'vm', + tags: 'batch;pulse-relaxed' as unknown as string[], + }); + expect(getWorkloadAlertPolicyTags(guest)).toEqual(['batch', 'pulse-relaxed']); + expect(getWorkloadAlertPolicyTags(makeGuest(7, { type: 'vm', tags: null as never }))).toEqual( + [], + ); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts index 9763e5b949..28981509e5 100644 --- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts @@ -24,6 +24,7 @@ import { import { getDiscoveryHostIdForWorkload, getDiscoveryResourceIdForWorkload, + getWorkloadAlertPolicyTags, getWorkloadAlertResourceIdCandidates, getWorkloadAlertThresholdScope, } from './workloadTopology'; @@ -66,12 +67,14 @@ export function useGuestDrawerState(props: GuestDrawerProps) { const alertResourceIdCandidates = createMemo(() => getWorkloadAlertResourceIdCandidates(props.guest), ); + const alertPolicyTags = createMemo(() => getWorkloadAlertPolicyTags(props.guest)); const metricThresholds = (metric: DisplayMetricType) => createMemo(() => alertsActivation.getMetricThresholds( alertThresholdScope(), metric, alertResourceIdCandidates(), + alertPolicyTags(), ), ); const diskThresholds = metricThresholds('disk'); diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index bdbc76c2b2..926e356ce6 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -23,6 +23,7 @@ import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePrese import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges'; import { + getWorkloadAlertPolicyTags, getWorkloadAlertResourceIdCandidates, getWorkloadAlertThresholdScope, getWorkloadDockerHostId, @@ -61,12 +62,14 @@ export function useGuestRowState(props: GuestRowProps) { const alertResourceIdCandidates = createMemo(() => getWorkloadAlertResourceIdCandidates(props.guest), ); + const alertPolicyTags = createMemo(() => getWorkloadAlertPolicyTags(props.guest)); const metricThresholds = (metric: DisplayMetricType) => createMemo(() => alertsActivation.getMetricThresholds( alertThresholdScope(), metric, alertResourceIdCandidates(), + alertPolicyTags(), ), ); const cpuThresholds = metricThresholds('cpu'); diff --git a/frontend-modern/src/components/Workloads/workloadTopology.ts b/frontend-modern/src/components/Workloads/workloadTopology.ts index 11a758b508..8e702bae34 100644 --- a/frontend-modern/src/components/Workloads/workloadTopology.ts +++ b/frontend-modern/src/components/Workloads/workloadTopology.ts @@ -3,6 +3,7 @@ import type { WorkloadGuest } from '@/types/workloads'; import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity'; import { getCanonicalWorkloadId, + getWorkloadPlatformScopes, resolveDiscoveryTargetForWorkload, resolveWorkloadType, } from '@/utils/workloads'; @@ -55,6 +56,24 @@ export const getWorkloadAlertThresholdScope = (guest: WorkloadGuest): AlertThres return type === 'app-container' ? 'docker' : 'guest'; }; +/** + * Tags the alert engine reads for this workload's guest thresholds. It reads + * Pulse control tags such as pulse-relaxed only from Proxmox VMs and LXCs, so + * a vSphere VM or a container keeps its thresholds whatever it is tagged. + * Rows without platform scopes are the legacy Proxmox shape. + */ +export const getWorkloadAlertPolicyTags = (guest: WorkloadGuest): string[] => { + const type = resolveWorkloadType(guest); + if (type !== 'vm' && type !== 'system-container') return []; + const platformScopes = getWorkloadPlatformScopes(guest); + if (platformScopes.length > 0 && !platformScopes.includes('proxmox-pve')) return []; + const tags = guest.tags; + if (Array.isArray(tags)) return tags; + // Legacy rows carry the raw tag string: Proxmox separates with ';', + // older Pulse payloads with ','. + return typeof tags === 'string' ? tags.split(/[;,]/) : []; +}; + export const getWorkloadAlertResourceIdCandidates = (guest: WorkloadGuest): string[] => { const type = resolveWorkloadType(guest); if (type === 'vm' || type === 'system-container') { diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts index 96d39d060d..e90e5d0e5d 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts @@ -9,6 +9,8 @@ import { } from '@/features/alerts/guestOverrideIdentity'; import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; import type { Disk } from '@/types/api'; +import { hasPulseRelaxedGuestTag } from '@/utils/metricThresholds'; +import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes'; import { ThresholdsDataInputs } from '../thresholdsResourceModel'; @@ -46,6 +48,10 @@ export function useThresholdsGuestData(inputs: ThresholdsDataInputs) { Boolean(override?.disabled) || Boolean(override?.disableConnectivity) || overrideSeverity !== undefined; + // The alert engine reads pulse-relaxed only from Proxmox guests. + const platformType = resolveResourcePlatformType(guest); + const pulseRelaxed = + (!platformType || platformType === 'proxmox-pve') && hasPulseRelaxedGuestTag(guest.tags); return { id: guest.id, @@ -66,6 +72,7 @@ export function useThresholdsGuestData(inputs: ThresholdsDataInputs) { backup: override?.backup || props.backupDefaults(), snapshot: override?.snapshot || props.snapshotDefaults(), poweredOffSeverity: overrideSeverity, + pulseRelaxed, }; }); diff --git a/frontend-modern/src/features/alerts/thresholds/tableTypes.ts b/frontend-modern/src/features/alerts/thresholds/tableTypes.ts index f003e6bb47..f9831775e3 100644 --- a/frontend-modern/src/features/alerts/thresholds/tableTypes.ts +++ b/frontend-modern/src/features/alerts/thresholds/tableTypes.ts @@ -26,6 +26,8 @@ export interface Resource { disableConnectivity?: boolean; poweredOffSeverity?: 'warning' | 'critical'; hasOverride?: boolean; + /** A Proxmox guest tagged pulse-relaxed; the alert engine raises its CPU/memory/disk triggers. */ + pulseRelaxed?: boolean; status?: string; vmid?: number; cpu?: number; diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index 8ccdf15dfe..81fe367f23 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -146,8 +146,9 @@ const getMetricThresholds = ( scope: AlertThresholdScope, metric: DisplayMetricType, resourceIds?: string | string[], + guestTags?: readonly string[] | null, ) => { - return resolveMetricDisplayThresholds(config(), scope, metric, resourceIds); + return resolveMetricDisplayThresholds(config(), scope, metric, resourceIds, guestTags); }; // Per-type disk SMART temperature thresholds (for display coloring). diff --git a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts index 45dd2319ea..7099f3e0ad 100644 --- a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts +++ b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts @@ -7,6 +7,8 @@ import { getMetricTextColorClass, getDefaultDisplayMetricThresholds, getDefaultMetricDisplayThresholds, + getPulseRelaxedGuestTrigger, + hasPulseRelaxedGuestTag, resolveDiskTemperatureDisplayThresholds, resolveMetricDisplayThresholds, METRIC_THRESHOLDS, @@ -20,7 +22,7 @@ import { FACTORY_TRUENAS_DISK_DEFAULTS, FACTORY_VMWARE_DEFAULTS, } from '@/utils/alertThresholdDefaults'; -import type { AlertConfig } from '@/types/alerts'; +import type { AlertConfig, HysteresisThreshold } from '@/types/alerts'; describe('metricThresholds', () => { describe('getMetricSeverity', () => { @@ -307,6 +309,167 @@ describe('metricThresholds', () => { expect(getMetricColorClass(99, 'disk', null)).toContain('bg-metric-critical-bg'); }); + describe('pulse-relaxed guests', () => { + const relaxedConfig = (overrides: AlertConfig['overrides'] = {}) => + ({ + enabled: true, + guestDefaults: { + cpu: { trigger: 80, clear: 75 }, + memory: { trigger: 85, clear: 80 }, + disk: { trigger: 90, clear: 85 }, + }, + nodeDefaults: {}, + storageDefault: { trigger: 85, clear: 80 }, + overrides, + }) as AlertConfig; + + it('turns critical where the relaxed alert fires, keeping the configured clear', () => { + const config = relaxedConfig(); + const tags = ['batch', 'pulse-relaxed']; + + // applyRelaxedGuestThresholds lifts the trigger and keeps a clear + // below it, so the warning band starts at the configured clear. + expect(resolveMetricDisplayThresholds(config, 'guest', 'cpu', [], tags)).toEqual({ + warning: 75, + critical: 95, + }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'memory', [], tags)).toEqual({ + warning: 80, + critical: 92, + }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'disk', [], tags)).toEqual({ + warning: 85, + critical: 95, + }); + + // 88% memory raises no alert below the 92% relaxed trigger, so the + // bar must not read critical; untagged, the same reading does. + const relaxedMemory = resolveMetricDisplayThresholds(config, 'guest', 'memory', [], tags); + expect(getMetricSeverity(88, 'memory', relaxedMemory)).toBe('warning'); + expect(getMetricSeverity(92, 'memory', relaxedMemory)).toBe('critical'); + const plainMemory = resolveMetricDisplayThresholds(config, 'guest', 'memory', []); + expect(getMetricSeverity(88, 'memory', plainMemory)).toBe('critical'); + }); + + it('matches the tag the way the backend parses it', () => { + const config = relaxedConfig(); + expect(hasPulseRelaxedGuestTag([' Pulse-Relaxed '])).toBe(true); + expect(hasPulseRelaxedGuestTag(['pulse-relaxed-later', 'relaxed'])).toBe(false); + expect(hasPulseRelaxedGuestTag(null)).toBe(false); + expect( + resolveMetricDisplayThresholds(config, 'guest', 'cpu', [], [' PULSE-RELAXED ']), + ).toEqual({ warning: 75, critical: 95 }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'cpu', [], ['relaxed'])).toEqual({ + warning: 75, + critical: 80, + }); + }); + + it('keeps higher overrides and Off thresholds, and floors unset ones', () => { + const config = relaxedConfig({ + 'guest:cluster-a:100': { + cpu: { trigger: 97, clear: 90 }, + memory: { trigger: -1, clear: 0 }, + }, + }); + const ids = ['guest:cluster-a:100']; + const tags = ['pulse-relaxed']; + + expect(resolveMetricDisplayThresholds(config, 'guest', 'cpu', ids, tags)).toEqual({ + warning: 90, + critical: 97, + }); + // Relaxing never turns on an alert the config switched off. + expect(resolveMetricDisplayThresholds(config, 'guest', 'memory', ids, tags)).toBeNull(); + + const unset = { ...relaxedConfig(), guestDefaults: {} } as AlertConfig; + expect(resolveMetricDisplayThresholds(unset, 'guest', 'memory', [], tags)).toEqual({ + warning: 87, + critical: 92, + }); + expect(resolveMetricDisplayThresholds(null, 'guest', 'disk', [], tags)).toEqual({ + warning: 90, + critical: 95, + }); + }); + + it('fills a missing clear before lifting, and moves a clear the trigger passes', () => { + const config = relaxedConfig({ + 'guest:cluster-a:101': { + cpu: { trigger: 85 } as HysteresisThreshold, + // Legacy overrides stored a bare trigger number. + memory: 90 as unknown as HysteresisThreshold, + disk: { trigger: 93, clear: 96 }, + }, + }); + const ids = ['guest:cluster-a:101']; + const tags = ['pulse-relaxed']; + + expect(resolveMetricDisplayThresholds(config, 'guest', 'cpu', ids, tags)).toEqual({ + warning: 80, + critical: 95, + }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'memory', ids, tags)).toEqual({ + warning: 85, + critical: 92, + }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'disk', ids, tags)).toEqual({ + warning: 90, + critical: 95, + }); + }); + + it('fills a missing clear five below the trigger whatever the hysteresis margin', () => { + // ensureHysteresisThreshold uses a fixed 5, not hysteresisMargin. + const config = { + ...relaxedConfig({ + 'guest:cluster-a:102': { + cpu: { trigger: 85 } as HysteresisThreshold, + memory: 90 as unknown as HysteresisThreshold, + }, + }), + hysteresisMargin: 10, + } as AlertConfig; + const ids = ['guest:cluster-a:102']; + const tags = ['pulse-relaxed']; + + expect(resolveMetricDisplayThresholds(config, 'guest', 'cpu', ids, tags)).toEqual({ + warning: 80, + critical: 95, + }); + expect(resolveMetricDisplayThresholds(config, 'guest', 'memory', ids, tags)).toEqual({ + warning: 85, + critical: 92, + }); + }); + + it('relaxes only guest CPU, memory and disk', () => { + const config = { + ...relaxedConfig(), + dockerDefaults: { cpu: { trigger: 80, clear: 75 } }, + } as AlertConfig; + const tags = ['pulse-relaxed']; + + expect(resolveMetricDisplayThresholds(config, 'docker', 'cpu', [], tags)).toEqual({ + warning: 75, + critical: 80, + }); + expect(resolveMetricDisplayThresholds(config, 'node', 'cpu', [], tags)).toEqual({ + warning: 75, + critical: 80, + }); + }); + + it('derives the relaxed trigger from the configured one', () => { + expect(getPulseRelaxedGuestTrigger('cpu', 80)).toBe(95); + expect(getPulseRelaxedGuestTrigger('memory', 85)).toBe(92); + expect(getPulseRelaxedGuestTrigger('disk', 97)).toBe(97); + expect(getPulseRelaxedGuestTrigger('disk', undefined)).toBe(95); + expect(getPulseRelaxedGuestTrigger('cpu', 0)).toBeNull(); + expect(getPulseRelaxedGuestTrigger('cpu', -1)).toBeNull(); + }); + }); + it('honors disabled docker defaults and storage usage aliases', () => { const config = { enabled: true, diff --git a/frontend-modern/src/utils/metricThresholds.ts b/frontend-modern/src/utils/metricThresholds.ts index a892bb4ef2..2e4f2a061f 100644 --- a/frontend-modern/src/utils/metricThresholds.ts +++ b/frontend-modern/src/utils/metricThresholds.ts @@ -52,6 +52,79 @@ const DEFAULT_GENERIC_THRESHOLDS: MetricDisplayThresholds = { const DEFAULT_HYSTERESIS_MARGIN = 5; +/** + * Proxmox guest tag that lifts the guest's CPU, memory and disk alert triggers + * to a floor, mirroring `applyRelaxedGuestThresholds` in + * `internal/alerts/guest.go`. The alert engine reads Pulse control tags only + * from Proxmox VMs and LXCs, so callers pass a guest's tags only for those. + */ +const PULSE_RELAXED_GUEST_TAG = 'pulse-relaxed'; + +const PULSE_RELAXED_GUEST_TRIGGER_FLOORS: Readonly> = { + cpu: 95, + memory: 92, + disk: 95, +}; + +// The backend fills a missing clear point with a fixed 5-point margin, not +// the configured hysteresis margin. +const RELAXED_GUEST_CLEAR_MARGIN = 5; + +export const hasPulseRelaxedGuestTag = (tags?: readonly unknown[] | null): boolean => + (tags ?? []).some( + (tag) => typeof tag === 'string' && tag.trim().toLowerCase() === PULSE_RELAXED_GUEST_TAG, + ); + +const isRelaxedGuestMetric = (metric: DisplayMetricType): metric is MetricType => + metric === 'cpu' || metric === 'memory' || metric === 'disk'; + +/** + * The alert trigger a pulse-relaxed guest uses for `metric`, given the + * trigger its configuration resolves to. Unset gets the floor, a trigger + * below the floor is raised to it, and Off (`<= 0`) stays Off (null): the + * tag never turns on an alert the configuration switched off. + */ +export const getPulseRelaxedGuestTrigger = ( + metric: MetricType, + configuredTrigger: number | undefined, +): number | null => { + const floor = PULSE_RELAXED_GUEST_TRIGGER_FLOORS[metric]; + if (configuredTrigger === undefined) { + return floor; + } + if (!Number.isFinite(configuredTrigger) || configuredTrigger <= 0) { + return null; + } + return Math.max(configuredTrigger, floor); +}; + +// Applies the relaxed floor to one resolved threshold value, step for step +// with the backend: a missing clear point (or a legacy bare trigger number) +// is filled five below the configured trigger before the trigger is lifted, +// and the clear only moves when it would reach the lifted trigger. +const relaxGuestThresholdValue = ( + metric: MetricType, + value: number | HysteresisThreshold | undefined, +): number | HysteresisThreshold | undefined => { + if (value === undefined) { + const floor = PULSE_RELAXED_GUEST_TRIGGER_FLOORS[metric]; + return { trigger: floor, clear: floor - RELAXED_GUEST_CLEAR_MARGIN }; + } + const configuredTrigger = toFiniteNumber(isHysteresisThreshold(value) ? value.trigger : value); + if (configuredTrigger === null || configuredTrigger <= 0) { + return value; + } + let clear = isHysteresisThreshold(value) ? toFiniteNumber(value.clear) : null; + if (clear === null || clear <= 0) { + clear = configuredTrigger - RELAXED_GUEST_CLEAR_MARGIN; + } + const trigger = getPulseRelaxedGuestTrigger(metric, configuredTrigger) ?? configuredTrigger; + if (clear >= trigger) { + clear = trigger - RELAXED_GUEST_CLEAR_MARGIN; + } + return { trigger, clear: Math.max(0, clear) }; +}; + const DISPLAY_METRIC_DEFAULT_SCOPES: Record = { cpu: 'guest', memory: 'guest', @@ -251,18 +324,30 @@ export const getDefaultDisplayMetricThresholds = ( ): MetricDisplayThresholds | null => resolveThreshold(undefined, getFallbackCritical(scope, metric), DEFAULT_HYSTERESIS_MARGIN); +/** + * Resolve display thresholds the way the alert engine resolves the trigger: + * the per-resource override, then the scope default, then the factory + * default. `guestTags` are the tags the engine reads for a guest-scope + * resource; a `pulse-relaxed` tag lifts CPU, memory and disk to the relaxed + * floor, so the bar turns critical where the alert would fire. + */ export const resolveMetricDisplayThresholds = ( config: AlertConfig | null, scope: AlertThresholdScope, metric: DisplayMetricType, resourceIds?: string | string[], + guestTags?: readonly string[] | null, ): MetricDisplayThresholds | null => { const margin = normalizeMargin(config?.hysteresisMargin); const scopeThresholds = getScopeThresholds(config, scope); const override = findOverride(config?.overrides, resourceIds); const overrideValue = getOverrideValue(override, metric); const baseValue = getBaseThresholdValue(scopeThresholds, metric); - return resolveThreshold(overrideValue ?? baseValue, getFallbackCritical(scope, metric), margin); + let value = overrideValue ?? baseValue; + if (scope === 'guest' && isRelaxedGuestMetric(metric) && hasPulseRelaxedGuestTag(guestTags)) { + value = relaxGuestThresholdValue(metric, value); + } + return resolveThreshold(value, getFallbackCritical(scope, metric), margin); }; /**