feat: add ON/OFF toggle for host threshold alerts (#1456)

* feat: add ON/OFF toggle for host threshold alerts

Add host_alerts_enabled setting (default ON) as a master switch for CPU,
RAM, and disk host threshold evaluation. When OFF, the four threshold
controls in Settings > Host Alerts are disabled and MonitorService skips
the systeminformation calls and alert dispatch entirely, while clearing
stale suppression state so re-enabling starts fresh.

The dashboard Configuration Status card shows "Off" when host threshold
alerts are disabled. Crash capture, health gate, deploy guardrails,
stack alert rules, and the Docker janitor are all unaffected.

* fix: exit NumberChip edit mode when externally disabled

When the host threshold alerts master toggle is turned OFF while a
NumberChip is in edit mode, force-exit edit mode so the chip renders
the greyed-out button state consistently with the other chips.
This commit is contained in:
Anso
2026-06-25 16:05:16 -04:00
committed by GitHub
parent f1f64ec7f6
commit b7dd9dc1b0
19 changed files with 281 additions and 50 deletions
@@ -208,7 +208,9 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
)}
<Row
label="Alert thresholds"
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
value={thresholds.hostAlertsEnabled === false
? 'Off'
: `CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
onClick={open('host-alerts')}
/>
<Row
@@ -33,7 +33,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false },
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
...overrides,
};
@@ -118,3 +118,32 @@ describe('ConfigurationStatus row visibility', () => {
expect(screen.getByText('Google')).toBeDefined();
});
});
describe('ConfigurationStatus threshold display', () => {
it('renders threshold values when hostAlertsEnabled is true', () => {
useConfigurationStatusMock.mockReturnValue({
status: makePayload({ thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: true } }),
loading: false,
});
render(<ConfigurationStatus />);
expect(screen.getByText('CPU 80% · RAM 85% · Disk 90%')).toBeDefined();
});
it('renders OFF badge when hostAlertsEnabled is false', () => {
useConfigurationStatusMock.mockReturnValue({
status: makePayload({
thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: false },
}),
loading: false,
});
render(<ConfigurationStatus />);
// StatusBadge uppercases 'Off' to 'OFF'. Since backup.provider is
// 'disabled' (also rendered as OFF), there are two OFF badges.
// Verify the Alert thresholds row specifically shows OFF.
const thresholdRow = screen.getByText('Alert thresholds').closest('button');
expect(thresholdRow).toBeDefined();
// The OFF badge is the span inside the row that has font-mono + uppercase.
const badge = thresholdRow!.querySelector('.font-mono');
expect(badge?.textContent?.trim()).toBe('OFF');
});
});
@@ -51,7 +51,7 @@ beforeEach(() => {
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false },
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
})));
useNodesMock.mockReset();
@@ -37,6 +37,7 @@ export interface ConfigurationStatus {
diskLimit: number;
dockerJanitorGb: number;
globalCrash: boolean;
hostAlertsEnabled: boolean;
};
backup: {
provider: 'disabled' | 'sencho' | 'custom';
@@ -30,9 +30,10 @@ function SectionSkeleton() {
);
}
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash' | 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
type HostAlertFields = Pick<PatchableSettings, 'host_alerts_enabled' | 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash' | 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
const DEFAULT_HOST_ALERTS: HostAlertFields = {
host_alerts_enabled: DEFAULT_SETTINGS.host_alerts_enabled,
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
@@ -74,6 +75,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: HostAlertFields = {
host_alerts_enabled: (nodeData.host_alerts_enabled as '0' | '1') ?? DEFAULT_SETTINGS.host_alerts_enabled,
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
@@ -112,7 +114,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
return;
}
markSaved(submitted);
toast.success('Host alerts saved.');
toast.success('Host alert settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
@@ -125,6 +127,15 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Host thresholds">
<SettingsField
label="Host threshold alerts"
helper="Master switch for CPU, RAM, and disk threshold alerts only. When OFF, no host threshold checks run and the controls below are inactive. Crash capture, stack alert rules, and health gate checks are unaffected."
>
<TogglePill
checked={settings.host_alerts_enabled === '1'}
onChange={(next) => onSettingChange('host_alerts_enabled', next ? '1' : '0')}
/>
</SettingsField>
<SettingsField
label="CPU limit"
helper="Alerts fire when host CPU utilization exceeds this percentage."
@@ -136,6 +147,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
min={1}
max={100}
warnOver={95}
disabled={settings.host_alerts_enabled !== '1'}
/>
</SettingsField>
<SettingsField
@@ -149,6 +161,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
min={1}
max={100}
warnOver={95}
disabled={settings.host_alerts_enabled !== '1'}
/>
</SettingsField>
<SettingsField
@@ -162,6 +175,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
min={1}
max={100}
warnOver={95}
disabled={settings.host_alerts_enabled !== '1'}
/>
</SettingsField>
<SettingsField
@@ -174,6 +188,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
suffix="min"
min={1}
max={1440}
disabled={settings.host_alerts_enabled !== '1'}
/>
</SettingsField>
</SettingsSection>
@@ -9,9 +9,10 @@ interface NumberChipProps {
max?: number;
step?: number;
warnOver?: number;
disabled?: boolean;
}
export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver }: NumberChipProps) {
export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver, disabled }: NumberChipProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -20,7 +21,14 @@ export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOv
if (editing) inputRef.current?.select();
}, [editing]);
// When the chip is externally disabled (e.g. master toggle OFF), force
// exit from edit mode so the greyed-out button state is shown consistently.
useEffect(() => {
if (disabled) setEditing(false);
}, [disabled]);
const startEdit = () => {
if (disabled) return;
setDraft(value);
setEditing(true);
};
@@ -64,6 +72,7 @@ export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOv
if (e.key === 'Escape') setEditing(false);
}}
className="w-12 bg-transparent text-right outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
disabled={disabled}
/>
<span className="text-stat-subtitle">{suffix}</span>
</span>
@@ -75,6 +84,7 @@ export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOv
type="button"
className={cn(chipClass, 'focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed')}
onClick={startEdit}
disabled={disabled}
>
<span>{value || '0'}</span>
<span className="text-stat-subtitle">{suffix}</span>
@@ -35,6 +35,7 @@ const FULL_SETTINGS: Record<string, string> = {
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
host_alerts_enabled: '1',
global_crash: '1',
docker_janitor_gb: '5',
prune_on_update: '1',
@@ -63,7 +64,7 @@ describe('split section save payloads', () => {
it('HostAlertsSection patches only host alert and health gate keys', async () => {
render(<HostAlertsSection />);
const save = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getAllByRole('switch')[0]); // global_crash
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
@@ -72,6 +73,7 @@ describe('split section save payloads', () => {
'health_gate_enabled',
'health_gate_window_seconds',
'host_alert_suppression_mins',
'host_alerts_enabled',
'host_cpu_limit',
'host_disk_limit',
'host_ram_limit',
@@ -45,6 +45,7 @@ const FULL_SETTINGS: Record<string, string> = {
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
host_alerts_enabled: '1',
global_crash: '1',
docker_janitor_gb: '5',
prune_on_update: '1',
@@ -84,7 +85,7 @@ describe('settings dirty reconcile on save', () => {
const save = await screen.findByRole('button', { name: /save alerts/i });
// Edit: section becomes dirty.
fireEvent.click(screen.getAllByRole('switch')[0]); // global_crash
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
expect(masthead.last?.[0]).toMatchObject({ label: 'EDITED', value: '1 pending', tone: 'warn' });
expect(save).not.toBeDisabled();
@@ -108,7 +109,7 @@ describe('settings dirty reconcile on save', () => {
render(<HostAlertsSection onDirtyChange={onDirty} />);
const save = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getAllByRole('switch')[0]);
fireEvent.click(screen.getAllByRole('switch')[1]); // (index 0 is host_alerts_enabled)
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
fireEvent.click(save);
@@ -133,13 +134,13 @@ describe('settings dirty reconcile on save', () => {
const save = await screen.findByRole('button', { name: /save alerts/i });
// Change field A and submit (PATCH now pending).
fireEvent.click(screen.getAllByRole('switch')[0]); // global_crash
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
// Change field B while the save is still in flight (fieldset stays editable).
const healthGate = screen.getAllByRole('switch')[1]; // health_gate_enabled
const healthGate = screen.getAllByRole('switch')[2]; // health_gate_enabled (0=host_alerts, 1=global_crash)
const healthBefore = healthGate.getAttribute('aria-checked');
fireEvent.click(healthGate);
expect(healthGate.getAttribute('aria-checked')).not.toBe(healthBefore);
@@ -169,7 +170,7 @@ describe('every migrated section clears its dirty flag on save', () => {
name: 'HostAlertsSection',
render: onDirty => render(<HostAlertsSection onDirtyChange={onDirty} />),
saveName: /save alerts/i,
edit: () => fireEvent.click(screen.getAllByRole('switch')[0]),
edit: () => fireEvent.click(screen.getAllByRole('switch')[1]), // global_crash (0=host_alerts_enabled)
},
{
name: 'DockerStorageSection',
@@ -3,6 +3,7 @@ export interface PatchableSettings {
host_ram_limit?: string;
host_disk_limit?: string;
host_alert_suppression_mins?: string;
host_alerts_enabled?: '0' | '1';
docker_janitor_gb?: string;
global_crash?: '0' | '1';
developer_mode?: '0' | '1';
@@ -25,6 +26,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
host_alerts_enabled: '1',
global_crash: '1',
docker_janitor_gb: '5',
developer_mode: '0',