From b7dd9dc1b079e49437171239818dd1baf432e008 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 25 Jun 2026 16:05:16 -0400 Subject: [PATCH] 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. --- backend/src/__tests__/monitor-service.test.ts | 102 +++++++++++++++++- backend/src/__tests__/settings-routes.test.ts | 51 +++++++++ backend/src/routes/dashboard.ts | 3 + backend/src/routes/settings.ts | 2 + backend/src/services/DatabaseService.ts | 1 + backend/src/services/DiagnosticsService.ts | 1 + backend/src/services/MonitorService.ts | 80 +++++++------- docs/features/alerts-notifications.mdx | 2 + docs/features/dashboard.mdx | 2 +- docs/reference/settings.mdx | 1 + .../dashboard/ConfigurationStatus.tsx | 4 +- .../__tests__/ConfigurationStatus.test.tsx | 31 +++++- .../__tests__/useConfigurationStatus.test.tsx | 2 +- .../dashboard/useConfigurationStatus.ts | 1 + .../components/settings/HostAlertsSection.tsx | 19 +++- .../components/settings/SystemControls.tsx | 12 ++- .../__tests__/SectionSavePayloads.test.tsx | 4 +- .../__tests__/SettingsDirtyReconcile.test.tsx | 11 +- frontend/src/components/settings/types.ts | 2 + 19 files changed, 281 insertions(+), 50 deletions(-) diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 120f84c8..5ee320fe 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -2,7 +2,7 @@ * Unit tests for MonitorService — alert state machine, metric calculations, * cleanup delegation, global settings evaluation, and concurrency guards. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── @@ -370,6 +370,106 @@ describe('MonitorService - evaluateGlobalSettings', () => { }); }); +describe('MonitorService - host_alerts_enabled toggle', () => { + beforeEach(() => { + mockGetGlobalSettings.mockReturnValue({}); + mockDispatchAlert.mockClear(); + mockCurrentLoad.mockClear(); + mockMem.mockClear(); + mockFsSize.mockClear(); + }); + + afterEach(() => { + // Restore default mock implementations so version-check state does not + // leak into sibling describe blocks (F-11 suppression, version check). + mockGetLatestVersion.mockResolvedValue(null); + mockGetSenchoVersion.mockReturnValue(null); + }); + + it('skips host metrics and dispatch when disabled', async () => { + mockCurrentLoad.mockResolvedValue({ currentLoad: 75 }); + + const svc = MonitorService.getInstance(); + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_cpu_limit: '50' }); + + expect(mockDispatchAlert).not.toHaveBeenCalled(); + expect(mockCurrentLoad).not.toHaveBeenCalled(); + expect(mockMem).not.toHaveBeenCalled(); + expect(mockFsSize).not.toHaveBeenCalled(); + }); + + it('does not call systeminformation when disabled', async () => { + mockMem.mockResolvedValue(memSample(15e9)); // ~94% - would breach + + const svc = MonitorService.getInstance(); + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_ram_limit: '80' }); + + expect(mockCurrentLoad).not.toHaveBeenCalled(); + expect(mockMem).not.toHaveBeenCalled(); + expect(mockFsSize).not.toHaveBeenCalled(); + expect(mockDispatchAlert).not.toHaveBeenCalled(); + }); + + it('clears persisted suppression state when disabled', async () => { + const store: Record = {}; + mockGetSystemState.mockImplementation((key: string) => store[key] ?? null); + mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; }); + + // Simulate a prior breach that set the persisted suppression timestamp. + store['last_host_cpu_alert_ts'] = String(Date.now()); + store['last_host_ram_alert_ts'] = String(Date.now()); + store['last_host_disk_alert_ts'] = String(Date.now()); + + const svc = MonitorService.getInstance(); + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' }); + + expect(store['last_host_cpu_alert_ts']).toBe('0'); + expect(store['last_host_ram_alert_ts']).toBe('0'); + expect(store['last_host_disk_alert_ts']).toBe('0'); + }); + + it('re-enable fires fresh without suppressed suffix', async () => { + const store: Record = {}; + mockGetSystemState.mockImplementation((key: string) => store[key] ?? null); + mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; }); + mockMem.mockResolvedValue(memSample(15e9)); // ~94% - breaches 80% limit + + const svc = MonitorService.getInstance(); + + // Step 1: breach while enabled — fires alert and seeds suppression state. + await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' }); + expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory')); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Suppressed')); + mockDispatchAlert.mockClear(); + + // Step 2: disable — clears suppression state and skips dispatch. + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0', host_ram_limit: '80' }); + expect(mockDispatchAlert).not.toHaveBeenCalled(); + expect(store['last_host_ram_alert_ts']).toBe('0'); + + // Step 3: re-enable while still breaching — fires fresh, no "Suppressed" suffix. + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '1', host_ram_limit: '80' }); + const calls = mockDispatchAlert.mock.calls.filter( + (c: unknown[]) => c[0] === 'warning' && c[1] === 'monitor_alert', + ); + expect(calls.length).toBe(1); + const msg = calls[0] as string[]; + expect(msg[2]).toContain('Memory'); + expect(msg[2]).not.toContain('Suppressed'); + }); + + it('still runs version check when disabled', async () => { + mockGetSenchoVersion.mockReturnValue('0.45.0'); + mockGetLatestVersion.mockResolvedValue('0.46.0'); + + const svc = MonitorService.getInstance(); + (svc as any).lastVersionCheckAt = 0; + await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' }); + + expect(mockGetLatestVersion).toHaveBeenCalled(); + }); +}); + // Crash + healthcheck detection now lives in DockerEventService (event-driven). // Tests for those flows live in docker-event-service.test.ts. diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index ffe56b48..c7828abd 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -337,6 +337,57 @@ describe('env_block_deploy_on_missing_required setting', () => { }); }); +describe('host_alerts_enabled toggle', () => { + it('seeds to "1" (on) in a fresh database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().host_alerts_enabled).toBe('1'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.host_alerts_enabled).toBeDefined(); + }); + + it('accepts a single-key POST write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'host_alerts_enabled', value: '0' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().host_alerts_enabled).toBe('0'); + DatabaseService.getInstance().updateGlobalSetting('host_alerts_enabled', '1'); + }); + + it('accepts a bulk PATCH alongside another key', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({ host_alerts_enabled: '0', host_cpu_limit: 75 }); + expect(res.status).toBe(200); + const settings = DatabaseService.getInstance().getGlobalSettings(); + expect(settings.host_alerts_enabled).toBe('0'); + expect(settings.host_cpu_limit).toBe('75'); + DatabaseService.getInstance().updateGlobalSetting('host_alerts_enabled', '1'); + DatabaseService.getInstance().updateGlobalSetting('host_cpu_limit', '90'); + }); + + it('rejects non-enum values with 400', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'host_alerts_enabled', value: '2' }); + expect(res.status).toBe(400); + }); + + it('rejects viewer write with 403', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', viewerCookie) + .send({ key: 'host_alerts_enabled', value: '0' }); + expect(res.status).toBe(403); + }); +}); + describe('PATCH /api/settings (bulk update)', () => { it('rejects unauthenticated requests with 401', async () => { const res = await request(app).patch('/api/settings').send({ host_cpu_limit: 50 }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index f1428ac7..833638bd 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -37,6 +37,7 @@ export interface ConfigurationStatus { diskLimit: number; dockerJanitorGb: number; globalCrash: boolean; + hostAlertsEnabled: boolean; }; backup: { provider: 'disabled' | 'sencho' | 'custom'; @@ -80,6 +81,7 @@ export function buildLocalConfigurationStatus( const diskLimit = parseInt(settings['host_disk_limit'] ?? '90', 10); const dockerJanitorGb = parseFloat(settings['docker_janitor_gb'] ?? '5'); const globalCrash = settings['global_crash'] === '1'; + const hostAlertsEnabled = settings['host_alerts_enabled'] !== '0'; const cloudSvc = CloudBackupService.getInstance(); const cloudProvider = cloudSvc.getProvider(); @@ -140,6 +142,7 @@ export function buildLocalConfigurationStatus( diskLimit, dockerJanitorGb, globalCrash, + hostAlertsEnabled, }, backup: { // Cloud Backup has a per-provider tier: Custom S3 is open to every diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 674dc85e..03b07325 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -14,6 +14,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'host_cpu_limit', 'host_ram_limit', 'host_disk_limit', + 'host_alerts_enabled', 'host_alert_suppression_mins', 'docker_janitor_gb', 'global_crash', @@ -42,6 +43,7 @@ const SettingsPatchSchema = z.object({ host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_alerts_enabled: z.enum(['0', '1']), host_alert_suppression_mins: z.coerce.number().int().min(1).max(1440).transform(String), docker_janitor_gb: z.coerce.number().min(0).transform(String), global_crash: z.enum(['0', '1']), diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index e893b909..c10c211e 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1564,6 +1564,7 @@ export class DatabaseService { stmt.run('host_ram_limit', '90'); stmt.run('host_disk_limit', '90'); stmt.run('host_alert_suppression_mins', '60'); + stmt.run('host_alerts_enabled', '1'); stmt.run('global_crash', '1'); stmt.run('docker_janitor_gb', '5'); stmt.run('developer_mode', '0'); diff --git a/backend/src/services/DiagnosticsService.ts b/backend/src/services/DiagnosticsService.ts index d87f3806..bc5c7982 100644 --- a/backend/src/services/DiagnosticsService.ts +++ b/backend/src/services/DiagnosticsService.ts @@ -26,6 +26,7 @@ const SAFE_SETTING_KEYS = [ 'host_cpu_limit', 'host_ram_limit', 'host_disk_limit', + 'host_alerts_enabled', 'host_alert_suppression_mins', 'docker_janitor_gb', 'global_crash', diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 5afd7db0..a96289a7 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -283,46 +283,54 @@ export class MonitorService { const suppressionMs = suppressionMin * 60 * 1000; // 1. Host Limits — fetch CPU, RAM, disk concurrently - try { - const [currentLoad, mem, fsSize] = await Promise.all([ - withTimeout(si.currentLoad(), STATS_TIMEOUT_MS, 'host CPU stats'), - withTimeout(si.mem(), STATS_TIMEOUT_MS, 'host RAM stats'), - withTimeout(si.fsSize(), STATS_TIMEOUT_MS, 'host disk stats'), - ]); + if (settings['host_alerts_enabled'] !== '0') { + try { + const [currentLoad, mem, fsSize] = await Promise.all([ + withTimeout(si.currentLoad(), STATS_TIMEOUT_MS, 'host CPU stats'), + withTimeout(si.mem(), STATS_TIMEOUT_MS, 'host RAM stats'), + withTimeout(si.fsSize(), STATS_TIMEOUT_MS, 'host disk stats'), + ]); - const cpuUsage = currentLoad.currentLoad; - const cpuLimit = parseFloat(settings['host_cpu_limit']); - if (!isNaN(cpuLimit) && cpuLimit > 0 && cpuUsage > cpuLimit) { - await this.dispatchHostMetricAlert('cpu', 'warning', suppressionMs, - `Host CPU utilization is critically high: ${cpuUsage.toFixed(1)}% (Threshold: ${cpuLimit}%)`); - } else { - this.clearHostMetricSuppression('cpu'); - } - - // Key off mem.active (the real working set), not mem.used: on Linux/BSD/macOS - // mem.used counts reclaimable page cache and reads ~99% on a busy host, which - // would fire spurious host-memory alerts. - const ramUsage = (mem.active / mem.total) * 100; - const ramLimit = parseFloat(settings['host_ram_limit']); - if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { - await this.dispatchHostMetricAlert('ram', 'warning', suppressionMs, - `Host Memory utilization is critically high: ${ramUsage.toFixed(1)}% (Threshold: ${ramLimit}%)`); - } else { - this.clearHostMetricSuppression('ram'); - } - - const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; - if (mainDisk) { - const diskLimit = parseFloat(settings['host_disk_limit']); - if (!isNaN(diskLimit) && diskLimit > 0 && mainDisk.use > diskLimit) { - await this.dispatchHostMetricAlert('disk', 'warning', suppressionMs, - `Host Disk space utilization is critically high: ${mainDisk.use.toFixed(1)}% (Threshold: ${diskLimit}%)`); + const cpuUsage = currentLoad.currentLoad; + const cpuLimit = parseFloat(settings['host_cpu_limit']); + if (!isNaN(cpuLimit) && cpuLimit > 0 && cpuUsage > cpuLimit) { + await this.dispatchHostMetricAlert('cpu', 'warning', suppressionMs, + `Host CPU utilization is critically high: ${cpuUsage.toFixed(1)}% (Threshold: ${cpuLimit}%)`); } else { - this.clearHostMetricSuppression('disk'); + this.clearHostMetricSuppression('cpu'); } + + // Key off mem.active (the real working set), not mem.used: on Linux/BSD/macOS + // mem.used counts reclaimable page cache and reads ~99% on a busy host, which + // would fire spurious host-memory alerts. + const ramUsage = (mem.active / mem.total) * 100; + const ramLimit = parseFloat(settings['host_ram_limit']); + if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { + await this.dispatchHostMetricAlert('ram', 'warning', suppressionMs, + `Host Memory utilization is critically high: ${ramUsage.toFixed(1)}% (Threshold: ${ramLimit}%)`); + } else { + this.clearHostMetricSuppression('ram'); + } + + const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; + if (mainDisk) { + const diskLimit = parseFloat(settings['host_disk_limit']); + if (!isNaN(diskLimit) && diskLimit > 0 && mainDisk.use > diskLimit) { + await this.dispatchHostMetricAlert('disk', 'warning', suppressionMs, + `Host Disk space utilization is critically high: ${mainDisk.use.toFixed(1)}% (Threshold: ${diskLimit}%)`); + } else { + this.clearHostMetricSuppression('disk'); + } + } + } catch (e) { + console.error('Error checking host limits in watchdog', e); } - } catch (e) { - console.error('Error checking host limits in watchdog', e); + } else { + // Host threshold alerts are disabled: clear any stale suppression + // state so re-enabling starts fresh (no "Suppressed N" carryover). + this.clearHostMetricSuppression('cpu'); + this.clearHostMetricSuppression('ram'); + this.clearHostMetricSuppression('disk'); } // 2. (Removed) Container crash + healthcheck detection moved to diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 8ccb2204..752d113e 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -288,6 +288,8 @@ Each metric (CPU, RAM, disk) carries its own suppression window. The first time The suppression window defaults to 60 minutes and is configured per node in **Settings → Monitoring → Host Alerts → Alert suppression**. +The entire host threshold evaluation can be silenced per node from **Settings · Monitoring · Host Alerts · Host threshold alerts** while keeping the configured limit values. This toggle affects only the CPU, RAM, and disk threshold checks; crash capture, stack alert rules, and health gate checks all continue to run independently. + ### Docker janitor `info`/`system`: `Node "" has accumulated GB of unused Docker data. Consider using the Janitor tool.` 24-hour cooldown. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 0e8cda79..413eceb2 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -109,7 +109,7 @@ The card is divided into four sections. | Row | What it shows | |-----|---------------| | **Cloud Backup** | The active cloud backup target: `Sencho Cloud` (Admiral only), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` | -| **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%` | +| **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%`, or `Off` when host threshold alerts are disabled | | **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise | Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 7885ad77..f6857c0a 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -235,6 +235,7 @@ Configure the host resource thresholds that trigger warnings, the suppression ca | Setting | Default | Description | |---------|---------|-------------| +| **Host threshold alerts** | On | Master switch for CPU, RAM, and disk threshold alerts only. When OFF, no host threshold checks run and the controls below are inactive. | | **CPU limit** | 90% | Alerts fire when host CPU utilization exceeds this percentage. The input warns at values above 95%. | | **RAM limit** | 90% | Set this below the point at which the host starts paging to swap. | | **Disk limit** | 90% | Low free space slows image pulls and backups. | diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 4e505838..8d28363a 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -208,7 +208,9 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps )} = {}): 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(); + 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(); + // 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'); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx index 8ebce50e..fed3c4fa 100644 --- a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx @@ -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(); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index 783a33fb..ea94c0f9 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -37,6 +37,7 @@ export interface ConfigurationStatus { diskLimit: number; dockerJanitorGb: number; globalCrash: boolean; + hostAlertsEnabled: boolean; }; backup: { provider: 'disabled' | 'sencho' | 'custom'; diff --git a/frontend/src/components/settings/HostAlertsSection.tsx b/frontend/src/components/settings/HostAlertsSection.tsx index d48e9d51..19bc53fb 100644 --- a/frontend/src/components/settings/HostAlertsSection.tsx +++ b/frontend/src/components/settings/HostAlertsSection.tsx @@ -30,9 +30,10 @@ function SectionSkeleton() { ); } -type HostAlertFields = Pick; +type HostAlertFields = Pick; 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 = 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 (
+ + onSettingChange('host_alerts_enabled', next ? '1' : '0')} + /> + diff --git a/frontend/src/components/settings/SystemControls.tsx b/frontend/src/components/settings/SystemControls.tsx index a6ae68ed..b9215270 100644 --- a/frontend/src/components/settings/SystemControls.tsx +++ b/frontend/src/components/settings/SystemControls.tsx @@ -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(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} /> {suffix} @@ -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} > {value || '0'} {suffix} diff --git a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx index 7e2c7a0a..07a221e2 100644 --- a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx +++ b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx @@ -35,6 +35,7 @@ const FULL_SETTINGS: Record = { 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(); 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', diff --git a/frontend/src/components/settings/__tests__/SettingsDirtyReconcile.test.tsx b/frontend/src/components/settings/__tests__/SettingsDirtyReconcile.test.tsx index b6211b81..893488ab 100644 --- a/frontend/src/components/settings/__tests__/SettingsDirtyReconcile.test.tsx +++ b/frontend/src/components/settings/__tests__/SettingsDirtyReconcile.test.tsx @@ -45,6 +45,7 @@ const FULL_SETTINGS: Record = { 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(); 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(), 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', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index b34a7200..805e8f61 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -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',