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.
This commit is contained in:
rcourtman
2026-10-08 00:35:28 +01:00
parent 1a094646d7
commit aac5ca5fc2
19 changed files with 638 additions and 3 deletions
@@ -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
@@ -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
@@ -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
@@ -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"
]
}
@@ -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 (
<p
class={props.class}
title={props.summary.title}
data-testid="alert-resource-relaxed-triggers"
>
Proxmox tag <span class="whitespace-nowrap">pulse-relaxed</span>: alerts at{' '}
<For each={props.summary.raised}>
{(threshold, index) => (
<>
{index() > 0 ? ' · ' : ''}
<span class="whitespace-nowrap">{threshold}</span>
</>
)}
</For>
</p>
);
}
@@ -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 (
<Card
@@ -477,6 +487,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
</div>
</div>
<Show when={relaxedTriggerSummary()}>
{(summary) => (
<AlertResourceRelaxedTriggers summary={summary()} class="text-xs text-muted" />
)}
</Show>
<Show when={isEditing()}>
<FormTextarea
label="Override note"
@@ -32,6 +32,7 @@ import {
getAlertResourceMetricBounds,
getAlertResourceMetricDisplayValue,
getAlertResourceMetricStep,
getAlertResourceRelaxedTriggerSummary,
isAlertResourceMetricOff,
isAlertResourceMetricOverridden,
normalizeAlertResourceMetricKey,
@@ -41,6 +42,7 @@ import {
import type { Alert } from '@/types/api';
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
import { AlertResourceRelaxedTriggers } from './AlertResourceRelaxedTriggers';
type OfflineState = 'off' | 'warning' | 'critical';
@@ -94,6 +96,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
isEditing(),
);
const isOverridden = (metric: string) => 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)}
/>
</Show>
<Show when={relaxedTriggerSummary()}>
{(summary) => (
<AlertResourceRelaxedTriggers summary={summary()} class="mt-1 text-xs text-muted" />
)}
</Show>
<Show when={!isEditing() && props.resource.note}>
<p class="mt-2 text-xs italic text-muted wrap-break-word">
{props.resource.note as string}
@@ -586,6 +586,82 @@ describe('ResourceTable', () => {
});
});
describe('pulse-relaxed guests', () => {
const relaxedGuest = (overrides: Partial<Resource> = {}) =>
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<string, any> = {}) =>
render(() => (
<ResourceTable
{...makeProps({
resources: undefined,
groupedResources: { node1: [resource] },
columns: ['CPU %', 'Memory %', 'Disk %'],
...overrides,
})}
/>
));
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) => {
@@ -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<string, string> = {
'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<MetricType, string> = {
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,
@@ -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',
@@ -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(
[],
);
});
});
});
@@ -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');
@@ -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');
@@ -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') {
@@ -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,
};
});
@@ -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;
@@ -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).
@@ -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,
+86 -1
View File
@@ -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<Record<MetricType, number>> = {
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<DisplayMetricType, AlertThresholdScope> = {
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);
};
/**