Add explicit per-metric off toggles to alert threshold editors

Disabling one metric for one resource required knowing to type -1 into
the override editor (the alert engine treats trigger <= 0 as disabled),
and the global-defaults row could only re-enable a disabled metric by
click, not disable one. Add an On/Off badge next to the threshold input
in the override row editor (desktop and mobile), in the global-defaults
row (desktop and mobile), and in the bulk edit dialog, mirroring the
existing Backup/Snapshot StatusBadge pattern in the same rows. The
toggle writes -1; toggling back on restores the inherit-default state
in override editors and the metric's enabled default in global
defaults. In the desktop row editor the badge swallows mousedown so it
doesn't blur the input, which would save and close the editor before
the click lands.

Also fix the off-value inconsistency found while triaging #1642: the
thresholds help banner told users to type 0, which the edit-state off
detection (=== -1) never recognises. Standardize the advertised
disable value on -1, matching docs/FAQ.md and the engine's actual off
sentinel; read-mode cells keep treating <= 0 as Off so overrides saved
as 0 under the old banner still display correctly.

Closes #1642.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contract-Neutral: UI-only off toggle over existing -1 threshold semantics; no alert truth or identity change
This commit is contained in:
courtmanr@gmail.com
2026-07-28 11:05:52 +01:00
parent 9e1e2b3823
commit 085208a824
13 changed files with 248 additions and 94 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ Pulse v6 organises the UI by **task** instead of **platform**:
Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) redirect automatically. See [Migration Guide](MIGRATION_UNIFIED_NAV.md) for the full mapping.
### Can I disable alerts for specific metrics?
Yes. Go to **Alerts → Thresholds** and set any value to `-1` to disable it. You can do this globally or per-resource (VM/Node).
Yes. Go to **Alerts → Thresholds** and use the On/Off toggle next to any metric while editing, or set the value to `-1`. You can do this globally or per-resource (VM/Node).
### How do I monitor temperature?
Recommended: install the unified agent on your Proxmox hosts with Proxmox integration enabled:
@@ -31,6 +31,7 @@ import {
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEmptyState,
getAlertResourceTableMetricInputTitle,
getAlertResourceTableMetricOffToggleProps,
getAlertResourceTableMetricPlaceholder,
getAlertResourceTableOfflineStateOrder,
getAlertResourceTableOfflineStatePresentation,
@@ -228,46 +229,59 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
<TableCell
class={`${getPlatformTableCellClassForKind(getAlertResourceColumnKind(column))} align-middle`}
>
<div class="relative flex justify-center w-full">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={isOff() ? '' : value()}
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
disabled={isOff()}
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: Number.isNaN(nextValue) ? 0 : nextValue,
}));
props.table.setHasUnsavedChanges?.(true);
}}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
isOff()
? 'border-border bg-surface-alt text-muted italic placeholder: dark:placeholder: placeholder:opacity-60 pointer-events-none'
: 'border-border text-base-content focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
}`}
title={getAlertResourceTableMetricInputTitle(isOff())}
/>
<Show when={isOff()}>
<button
type="button"
class="absolute inset-0 w-full rounded cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
onClick={() => {
<div class="flex items-center justify-center gap-1.5 w-full">
<div class="relative">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={isOff() ? '' : value()}
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
disabled={isOff()}
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: getAlertResourceEnabledDefault(metric),
[metric]: Number.isNaN(nextValue) ? 0 : nextValue,
}));
props.table.setHasUnsavedChanges?.(true);
}}
title={getAlertResourceTableMetricInputTitle(true)}
>
<span class="sr-only">Enable {column} default</span>
</button>
</Show>
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
isOff()
? 'border-border bg-surface-alt text-muted italic placeholder: dark:placeholder: placeholder:opacity-60 pointer-events-none'
: 'border-border text-base-content focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
}`}
title={getAlertResourceTableMetricInputTitle(isOff())}
/>
<Show when={isOff()}>
<button
type="button"
class="absolute inset-0 w-full rounded cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
onClick={() => {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: getAlertResourceEnabledDefault(metric),
}));
props.table.setHasUnsavedChanges?.(true);
}}
title={getAlertResourceTableMetricInputTitle(true)}
>
<span class="sr-only">Enable {column} default</span>
</button>
</Show>
</div>
<StatusBadge
isEnabled={!isOff()}
onToggle={() => {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: isOff() ? getAlertResourceEnabledDefault(metric) : -1,
}));
props.table.setHasUnsavedChanges?.(true);
}}
{...getAlertResourceTableMetricOffToggleProps()}
/>
</div>
</TableCell>
);
@@ -8,11 +8,13 @@ import { ActionIconButton } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { AlertResourceGroupHeader } from './AlertResourceGroupHeader';
import {
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEditNotePlaceholder,
getAlertResourceTableEmptyState,
getAlertResourceTableMetricOffToggleProps,
getAlertResourceTableMetricPlaceholder,
getAlertResourceTableRevertToDefaultsLabel,
} from '@/utils/alertResourceTablePresentation';
@@ -155,39 +157,52 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
return (
<div class="p-2 bg-surface rounded border border-border-subtle flex flex-col gap-1">
<span class="text-[10px] uppercase text-slate-500 font-medium">{column}</span>
<div class="relative">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={isOff() ? '' : value()}
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
disabled={isOff()}
class={`w-full text-sm p-1 rounded border text-center ${isOff() ? 'bg-surface-hover' : ' border-border'}`}
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: Number.isNaN(nextValue) ? 0 : nextValue,
}));
props.table.setHasUnsavedChanges?.(true);
}}
/>
<Show when={isOff()}>
<button
type="button"
class="absolute inset-0 w-full"
onClick={() => {
<div class="flex items-center gap-1.5">
<div class="relative flex-1">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={isOff() ? '' : value()}
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
disabled={isOff()}
class={`w-full text-sm p-1 rounded border text-center ${isOff() ? 'bg-surface-hover' : ' border-border'}`}
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: getAlertResourceEnabledDefault(metric),
[metric]: Number.isNaN(nextValue) ? 0 : nextValue,
}));
props.table.setHasUnsavedChanges?.(true);
}}
aria-label={`Enable ${column} default`}
/>
</Show>
<Show when={isOff()}>
<button
type="button"
class="absolute inset-0 w-full"
onClick={() => {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: getAlertResourceEnabledDefault(metric),
}));
props.table.setHasUnsavedChanges?.(true);
}}
aria-label={`Enable ${column} default`}
/>
</Show>
</div>
<StatusBadge
isEnabled={!isOff()}
onToggle={() => {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: isOff() ? getAlertResourceEnabledDefault(metric) : -1,
}));
props.table.setHasUnsavedChanges?.(true);
}}
{...getAlertResourceTableMetricOffToggleProps()}
/>
</div>
</div>
);
@@ -350,21 +365,35 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
</button>
}
>
<input
type="number"
min={bounds.min}
max={bounds.max}
value={thresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(isDisabled())}
class="w-16 text-right text-xs p-1 rounded border border-border bg-surface"
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setEditingThresholds({
...props.table.editingThresholds(),
[metric]: Number.isNaN(nextValue) ? undefined : nextValue,
});
}}
/>
<div class="flex items-center gap-1.5">
<input
type="number"
min={bounds.min}
max={bounds.max}
value={isDisabled() ? '' : (thresholds()?.[metric] ?? '')}
placeholder={getAlertResourceTableMetricPlaceholder(
isDisabled(),
)}
class="w-16 text-right text-xs p-1 rounded border border-border bg-surface"
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setEditingThresholds({
...props.table.editingThresholds(),
[metric]: Number.isNaN(nextValue) ? undefined : nextValue,
});
}}
/>
<StatusBadge
isEnabled={!isDisabled()}
onToggle={() =>
props.table.setEditingThresholds({
...props.table.editingThresholds(),
[metric]: isDisabled() ? undefined : -1,
})
}
{...getAlertResourceTableMetricOffToggleProps()}
/>
</div>
</Show>
</div>
);
@@ -15,6 +15,7 @@ import {
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEditMetricTitle,
getAlertResourceTableMetricInputTitle,
getAlertResourceTableMetricOffToggleProps,
getAlertResourceTableMetricPlaceholder,
getAlertResourceTableOfflineStateOrder,
getAlertResourceTableOfflineStatePresentation,
@@ -438,13 +439,13 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
);
})()}
</Show>
<div class="flex items-center justify-center">
<div class="flex items-center justify-center gap-1.5">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={getThresholds()?.[metric] ?? ''}
value={isDisabled() ? '' : (getThresholds()?.[metric] ?? '')}
placeholder={getAlertResourceTableMetricPlaceholder(isDisabled())}
title={getAlertResourceTableMetricInputTitle(isDisabled())}
ref={(el) => {
@@ -481,6 +482,17 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
: ' text-base-content border-border'
}`}
/>
{/* mousedown would blur the input, which saves and closes
the editor before this toggle's click can land */}
<div onMouseDown={(event) => event.preventDefault()}>
<StatusBadge
isEnabled={!isDisabled()}
onToggle={() =>
updateEditingThreshold(metric, isDisabled() ? undefined : -1)
}
{...getAlertResourceTableMetricOffToggleProps()}
/>
</div>
</div>
</div>
</Show>
@@ -1,6 +1,11 @@
import { Show, For, createSignal, createEffect } from 'solid-js';
import { Dialog } from '../shared/Dialog';
import { StatusBadge } from '../shared/StatusBadge';
import { ThresholdSlider } from '../Workloads/ThresholdSlider';
import {
getAlertResourceTableMetricOffToggleProps,
getAlertResourceTableMetricPlaceholder,
} from '@/utils/alertResourceTablePresentation';
import {
ALERT_BULK_EDIT_CANCEL_LABEL,
ALERT_BULK_EDIT_CLEAR_LABEL,
@@ -102,14 +107,31 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
if (metric === 'backup' || metric === 'snapshot') return null;
const bounds = getMetricBounds(metric);
const val = () => thresholds()[metric];
const isOff = () => val() === -1;
return (
<div class="space-y-2 pb-4 border-b border-border-subtle last:border-0">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-base-content">{column}</label>
<span class="text-xs text-slate-500 font-mono">
{val() !== undefined ? val() : ALERT_BULK_EDIT_UNCHANGED_LABEL}
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 font-mono">
{isOff()
? getAlertResourceTableMetricPlaceholder(true)
: val() !== undefined
? val()
: ALERT_BULK_EDIT_UNCHANGED_LABEL}
</span>
<StatusBadge
isEnabled={!isOff()}
onToggle={() =>
setThresholds((prev) => ({
...prev,
[metric]: isOff() ? undefined : -1,
}))
}
{...getAlertResourceTableMetricOffToggleProps()}
/>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex-1">
@@ -119,7 +141,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
type={metric as 'cpu' | 'memory' | 'disk' | 'temperature'}
min={bounds.min}
max={bounds.max}
value={val() !== undefined ? val()! : bounds.min}
value={val() !== undefined && !isOff() ? val()! : bounds.min}
onChange={(v) => {
setThresholds((prev) => ({ ...prev, [metric]: v }));
}}
@@ -132,8 +154,12 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
min={bounds.min}
max={bounds.max}
step={bounds.step}
value={val() ?? ''}
placeholder={ALERT_BULK_EDIT_UNCHANGED_LABEL}
value={isOff() ? '' : (val() ?? '')}
placeholder={
isOff()
? getAlertResourceTableMetricPlaceholder(true)
: ALERT_BULK_EDIT_UNCHANGED_LABEL
}
onInput={(e) => {
const v = parseFloat(e.currentTarget.value);
setThresholds((prev) => ({
@@ -1309,7 +1309,7 @@ describe('ResourceTable', () => {
// Find the editing input with the "Set to -1" title (unique to resource editing inputs)
const editInputs = document.querySelectorAll(
'input[type="number"][title="Set to -1 to disable alerts for this metric"]',
'input[type="number"][title="Set to -1 or use the Off toggle to disable alerts for this metric"]',
);
expect(editInputs.length).toBeGreaterThanOrEqual(1);
fireEvent.input(editInputs[0], { target: { value: '95' } });
@@ -1332,7 +1332,7 @@ describe('ResourceTable', () => {
render(() => <ResourceTable {...props} />);
const editInputs = document.querySelectorAll(
'input[type="number"][title="Set to -1 to disable alerts for this metric"]',
'input[type="number"][title="Set to -1 or use the Off toggle to disable alerts for this metric"]',
);
expect(editInputs.length).toBeGreaterThanOrEqual(1);
fireEvent.blur(editInputs[0]);
@@ -230,6 +230,49 @@ describe('BulkEditDialog', () => {
expect(saved.restartCount).toBeUndefined();
});
it('off toggle stages -1 for the metric and shows the Off state', () => {
const props = defaultProps();
props.columns = ['Restart Count'];
render(() => <BulkEditDialog {...props} />);
const toggle = screen.getByRole('button', { name: 'On' });
fireEvent.click(toggle);
// Badge flips to Off and the staged value reads Off, not -1
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument();
expect(screen.getByText('Off', { selector: 'span' })).toBeInTheDocument();
fireEvent.click(screen.getByText(/Apply to 3 items/));
expect(props.onSave).toHaveBeenCalledWith({ restartCount: -1 });
});
it('off toggle returns the metric to Unchanged when clicked again', () => {
const props = defaultProps();
props.columns = ['Restart Count'];
render(() => <BulkEditDialog {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'On' }));
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
expect(screen.getByText('Unchanged')).toBeInTheDocument();
fireEvent.click(screen.getByText(/Apply to 3 items/));
const saved = props.onSave.mock.calls[0][0] as Record<string, number | undefined>;
expect(saved.restartCount).toBeUndefined();
});
it('typing a value after turning a metric off replaces the staged -1', () => {
const props = defaultProps();
props.columns = ['Restart Count'];
render(() => <BulkEditDialog {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'On' }));
const input = screen.getByRole('spinbutton');
fireEvent.input(input, { target: { value: '7' } });
fireEvent.click(screen.getByText(/Apply to 3 items/));
expect(props.onSave).toHaveBeenCalledWith({ restartCount: 7 });
});
it('displays Unchanged when threshold is not set, value when it is', () => {
const props = defaultProps();
props.columns = ['Restart Count'];
@@ -122,7 +122,7 @@ describe('alertResourceTablePresentation.branchcov0718', () => {
it('exposes the enable/disable metric-input title constants', () => {
expect(ALERT_RESOURCE_TABLE_ENABLE_METRIC_TITLE).toBe('Click to enable this metric');
expect(ALERT_RESOURCE_TABLE_DISABLE_METRIC_TITLE).toBe(
'Set to -1 to disable alerts for this metric',
'Set to -1 or use the Off toggle to disable alerts for this metric',
);
});
@@ -20,6 +20,7 @@ import {
getAlertResourceTableEditNotePlaceholder,
getAlertResourceTableEmptyState,
getAlertResourceTableMetricInputTitle,
getAlertResourceTableMetricOffToggleProps,
getAlertResourceTableMetricPlaceholder,
getAlertResourceTableNoResultsState,
getAlertResourceTableOfflineStateOrder,
@@ -95,8 +96,17 @@ describe('alertResourceTablePresentation', () => {
});
expect(getAlertResourceTableMetricInputTitle(true)).toBe('Click to enable this metric');
expect(getAlertResourceTableMetricInputTitle(false)).toBe(
'Set to -1 to disable alerts for this metric',
'Set to -1 or use the Off toggle to disable alerts for this metric',
);
expect(getAlertResourceTableEditMetricTitle()).toBe('Click to edit this metric');
});
it('exports the per-metric off-toggle badge props', () => {
expect(getAlertResourceTableMetricOffToggleProps()).toEqual({
labelEnabled: 'On',
labelDisabled: 'Off',
titleEnabled: 'Alerts enabled for this metric. Click to turn off.',
titleDisabled: 'Alerts turned off for this metric. Click to re-enable.',
});
});
});
@@ -95,7 +95,10 @@ describe('alertThresholdsPresentation', () => {
expect(getAlertThresholdsHelpDismissLabel()).toBe('Dismiss tips');
expect(getAlertThresholdsHelpBanner()).toEqual({
title: 'Quick tips:',
disableValue: '0',
// Must match the value the backend and docs/FAQ.md treat as "off";
// the banner told users to type 0, which edit-state off detection
// (=== -1) never recognised.
disableValue: '-1',
reenableLabel: 'Off',
customBadgeLabel: 'Custom',
collapseHint: 'Click sections to collapse/expand.',
@@ -4123,7 +4123,9 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceTableSource).not.toContain('Reset to factory defaults');
expect(alertResourceTableSource).not.toContain('Alert Delay (s)');
expect(alertResourceTableSource).not.toContain('Click to edit this metric');
expect(alertResourceTableSource).not.toContain('Set to -1 to disable alerts for this metric');
expect(alertResourceTableSource).not.toContain(
'Set to -1 or use the Off toggle to disable alerts for this metric',
);
expect(alertResourceTableSource).not.toContain(
"if (resource.type === 'agent' && ['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(",
);
@@ -18,8 +18,14 @@ export const ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_TITLE =
'Offline alerts will raise critical-level notifications.';
export const ALERT_RESOURCE_TABLE_ENABLE_METRIC_TITLE = 'Click to enable this metric';
export const ALERT_RESOURCE_TABLE_DISABLE_METRIC_TITLE =
'Set to -1 to disable alerts for this metric';
'Set to -1 or use the Off toggle to disable alerts for this metric';
export const ALERT_RESOURCE_TABLE_EDIT_METRIC_TITLE = 'Click to edit this metric';
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_ON = 'On';
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_OFF = 'Off';
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_TITLE_ON =
'Alerts enabled for this metric. Click to turn off.';
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_TITLE_OFF =
'Alerts turned off for this metric. Click to re-enable.';
export function getAlertResourceTableEmptyState(emptyMessage?: string) {
return emptyMessage || ALERT_RESOURCE_TABLE_EMPTY_STATE;
@@ -100,3 +106,12 @@ export function getAlertResourceTableMetricInputTitle(isDisabled: boolean) {
export function getAlertResourceTableEditMetricTitle() {
return ALERT_RESOURCE_TABLE_EDIT_METRIC_TITLE;
}
export function getAlertResourceTableMetricOffToggleProps() {
return {
labelEnabled: ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_ON,
labelDisabled: ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_OFF,
titleEnabled: ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_TITLE_ON,
titleDisabled: ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_TITLE_OFF,
} as const;
}
@@ -98,7 +98,7 @@ export function getAlertThresholdsHelpDismissLabel() {
export function getAlertThresholdsHelpBanner() {
return {
title: 'Quick tips:',
disableValue: '0',
disableValue: '-1',
reenableLabel: 'Off',
customBadgeLabel: 'Custom',
collapseHint: 'Click sections to collapse/expand.',