mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user