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',