feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails (#1461)

* feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails

Move global_crash from Host Alerts to new Monitoring > Container Alerts section.
Move health gate and env deploy guardrails from Host Alerts to
Infrastructure > Stacks > Deploy Guardrails subsection.

Host Alerts now contains only host threshold settings (CPU, RAM, disk,
alert suppression, and the master host_alerts_enabled toggle).
Stacks gains a Deploy Guardrails subsection (node-scoped, admin-gated)
alongside the existing Workflow controls (browser-local).

Dashboard Crash detection row now routes to Container Alerts.

* docs: update crash detection toggle description to match new Container Alerts section
This commit is contained in:
Anso
2026-06-25 21:04:55 -04:00
committed by GitHub
parent 7320a86579
commit e9c262ae6a
18 changed files with 560 additions and 125 deletions
@@ -216,7 +216,7 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Crash detection"
value={thresholds.globalCrash ? 'On' : 'Off'}
onClick={open('host-alerts')}
onClick={open('container-alerts')}
/>
</div>
</CardContent>
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
const useConfigurationStatusMock = vi.fn();
vi.mock('../useConfigurationStatus', () => ({
@@ -147,3 +147,18 @@ describe('ConfigurationStatus threshold display', () => {
expect(badge?.textContent?.trim()).toBe('OFF');
});
});
describe('ConfigurationStatus click targets', () => {
it('routes Crash detection to container-alerts', () => {
const onOpenSection = vi.fn();
useConfigurationStatusMock.mockReturnValue({
status: makePayload(),
loading: false,
});
render(<ConfigurationStatus onOpenSection={onOpenSection} />);
const crashRow = screen.getByText('Crash detection').closest('button');
expect(crashRow).toBeDefined();
fireEvent.click(crashRow!);
expect(onOpenSection).toHaveBeenCalledWith('container-alerts');
});
});
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
interface ContainerAlertsSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
type ContainerAlertFields = Pick<PatchableSettings, 'global_crash'>;
const DEFAULT_CONTAINER_ALERTS: ContainerAlertFields = {
global_crash: DEFAULT_SETTINGS.global_crash,
};
export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSectionProps) {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<ContainerAlertFields>({ ...DEFAULT_CONTAINER_ALERTS });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: ContainerAlertFields = {
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch container alert settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof ContainerAlertFields>(key: K, value: ContainerAlertFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify({ global_crash: submitted.global_crash }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
markSaved(submitted);
toast.success('Container alert settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Container crash & health alerts">
<SettingsField
label="Container crash & health alerts"
helper="Send alerts for unexpected container exits, OOM kills, and Docker healthcheck failures. Auto-Heal can still observe crash signals independently."
>
<TogglePill
checked={settings.global_crash === '1'}
onChange={(c) => onSettingChange('global_crash', c ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -30,7 +30,7 @@ function SectionSkeleton() {
);
}
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'>;
type HostAlertFields = Pick<PatchableSettings, 'host_alerts_enabled' | 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins'>;
const DEFAULT_HOST_ALERTS: HostAlertFields = {
host_alerts_enabled: DEFAULT_SETTINGS.host_alerts_enabled,
@@ -38,10 +38,6 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = {
host_ram_limit: DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: DEFAULT_SETTINGS.host_alert_suppression_mins,
global_crash: DEFAULT_SETTINGS.global_crash,
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
};
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
@@ -80,10 +76,6 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
};
reset(safe);
} catch (e) {
@@ -129,7 +121,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
<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."
helper="Master switch for CPU, RAM, and disk threshold alerts only. When OFF, no host threshold checks run and the controls below are inactive. Stack alert rules are unaffected."
>
<TogglePill
checked={settings.host_alerts_enabled === '1'}
@@ -193,54 +185,6 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsSection title="Crash capture">
<SettingsField
label="Global crash capture"
helper="Watch every managed container for unexpected exits."
>
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Update health gate">
<SettingsField
label="Observe health after updates"
helper="After a stack deploy or update succeeds, watch its containers for the observation window and record a passed or failed verdict on the stack timeline. Observational only: nothing is restarted or rolled back automatically. On by default."
>
<TogglePill
checked={settings.health_gate_enabled === '1'}
onChange={(next) => onSettingChange('health_gate_enabled', next ? '1' : '0')}
/>
</SettingsField>
<SettingsField
label="Observation window"
helper="How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Default 90 seconds."
>
<NumberChip
value={settings.health_gate_window_seconds || '90'}
onChange={(v) => onSettingChange('health_gate_window_seconds', v)}
suffix="s"
min={15}
max={600}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Deploy guardrails">
<SettingsField
label="Block deploy on missing required env vars"
helper="When on, a deploy or update is refused before it starts if a required ${VAR:?message} variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. Off by default."
>
<TogglePill
checked={settings.env_block_deploy_on_missing_required === '1'}
onChange={(next) => onSettingChange('env_block_deploy_on_missing_required', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
@@ -7,6 +7,7 @@ import {
AppearanceSection,
LicenseSection,
HostAlertsSection,
ContainerAlertsSection,
DockerStorageSection,
UpdatesSection,
FleetMeshSection,
@@ -82,6 +83,7 @@ function renderSection(
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
case 'image-updates': return <UpdatesSection />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
@@ -93,7 +95,7 @@ function renderSection(
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'stacks': return <StacksSection />;
case 'stacks': return <StacksSection onDirtyChange={(d) => onDirtyChange('stacks', d)} />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
@@ -1,21 +1,130 @@
import { useState, useEffect } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
{ value: 'modal', label: 'Modal' },
{ value: 'inline', label: 'Inline' },
];
export function StacksSection() {
interface StacksSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
const DEFAULT_GUARDRAILS: GuardrailFields = {
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
};
function GuardrailSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
export function StacksSection({ onDirtyChange }: StacksSectionProps) {
// Browser-local workflow controls (unchanged, no backend fetch)
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
// Node-scoped deploy guardrails
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<GuardrailFields>({ ...DEFAULT_GUARDRAILS });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: GuardrailFields = {
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch deploy guardrail settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onGuardrailChange = <K extends keyof GuardrailFields>(key: K, value: GuardrailFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveGuardrails = async () => {
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(submitted),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
markSaved(submitted);
toast.success('Deploy guardrail settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
return (
<div className="flex flex-col gap-10">
<SettingsSection title="Workflow" kicker="this browser">
@@ -75,6 +184,63 @@ export function StacksSection() {
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
saved to this browser only · every device remembers its own choice
</p>
{isLoading ? (
<GuardrailSkeleton />
) : (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Deploy Guardrails" kicker="this node">
<p className="pb-2 text-sm leading-relaxed text-stat-subtitle">
Node-level safety checks and post-deploy observation used during stack deploys and updates.
</p>
<SettingsField
label="Observe health after updates"
helper="After a stack deploy or update succeeds, watch its containers for the observation window and record a passed or failed verdict on the stack timeline. Observational only: nothing is restarted or rolled back automatically. On by default."
>
<TogglePill
checked={settings.health_gate_enabled === '1'}
onChange={(next) => onGuardrailChange('health_gate_enabled', next ? '1' : '0')}
/>
</SettingsField>
<SettingsField
label="Observation window"
helper="How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Default 90 seconds."
>
<NumberChip
value={settings.health_gate_window_seconds || '90'}
onChange={(v) => onGuardrailChange('health_gate_window_seconds', v)}
suffix="s"
min={15}
max={600}
/>
</SettingsField>
<SettingsField
label="Block deploy on missing required env vars"
helper="When on, a deploy or update is refused before it starts if a required ${VAR:?message} variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. Off by default."
>
<TogglePill
checked={settings.env_block_deploy_on_missing_required === '1'}
onChange={(next) => onGuardrailChange('env_block_deploy_on_missing_required', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveGuardrails} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
)}
</div>
);
}
@@ -22,10 +22,12 @@ vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { HostAlertsSection } from '../HostAlertsSection';
import { ContainerAlertsSection } from '../ContainerAlertsSection';
import { DockerStorageSection } from '../DockerStorageSection';
import { FleetMeshSection } from '../FleetMeshSection';
import { DataRetentionSection } from '../DataRetentionSection';
import { DeveloperSection } from '../DeveloperSection';
import { StacksSection } from '../StacksSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedLicense = useLicense as unknown as ReturnType<typeof vi.fn>;
@@ -46,6 +48,9 @@ const FULL_SETTINGS: Record<string, string> = {
audit_retention_days: '90',
scan_history_per_image_limit: '50',
developer_mode: '0',
health_gate_enabled: '1',
health_gate_window_seconds: '90',
env_block_deploy_on_missing_required: '0',
};
function patchedKeys(): string[] {
@@ -61,17 +66,17 @@ beforeEach(() => {
});
describe('split section save payloads', () => {
it('HostAlertsSection patches only host alert and health gate keys', async () => {
it('HostAlertsSection patches only host alert keys', async () => {
render(<HostAlertsSection />);
const save = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
// NumberChip renders as a button until clicked; click the CPU chip to enter edit mode.
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
const spinbutton = screen.getByRole('spinbutton');
fireEvent.change(spinbutton, { target: { value: '95' } });
fireEvent.blur(spinbutton);
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'env_block_deploy_on_missing_required',
'global_crash',
'health_gate_enabled',
'health_gate_window_seconds',
'host_alert_suppression_mins',
'host_alerts_enabled',
'host_cpu_limit',
@@ -80,6 +85,30 @@ describe('split section save payloads', () => {
]);
});
it('ContainerAlertsSection patches only global_crash', async () => {
render(<ContainerAlertsSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // global_crash toggle
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['global_crash']);
});
it('StacksSection guardrails patch only deploy guardrail keys', async () => {
render(<StacksSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
// StacksSection has switches for health_gate_enabled and env_block. The first
// switch is the Observe health toggle.
fireEvent.click(screen.getAllByRole('switch')[0]);
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'env_block_deploy_on_missing_required',
'health_gate_enabled',
'health_gate_window_seconds',
]);
});
it('DockerStorageSection patches only docker and storage keys', async () => {
render(<DockerStorageSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
@@ -32,10 +32,12 @@ vi.mock('../MastheadStatsContext', () => ({
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { HostAlertsSection } from '../HostAlertsSection';
import { ContainerAlertsSection } from '../ContainerAlertsSection';
import { DockerStorageSection } from '../DockerStorageSection';
import { FleetMeshSection } from '../FleetMeshSection';
import { DataRetentionSection } from '../DataRetentionSection';
import { DeveloperSection } from '../DeveloperSection';
import { StacksSection } from '../StacksSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedLicense = useLicense as unknown as ReturnType<typeof vi.fn>;
@@ -56,6 +58,9 @@ const FULL_SETTINGS: Record<string, string> = {
audit_retention_days: '90',
scan_history_per_image_limit: '50',
developer_mode: '0',
health_gate_enabled: '1',
health_gate_window_seconds: '90',
env_block_deploy_on_missing_required: '0',
};
/** GET /settings resolves load data; PATCH resolves ok unless overridden per-test. */
@@ -84,8 +89,11 @@ describe('settings dirty reconcile on save', () => {
render(<HostAlertsSection onDirtyChange={onDirty} />);
const save = await screen.findByRole('button', { name: /save alerts/i });
// Edit: section becomes dirty.
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
// Edit via NumberChip: click chip button to mount spinbutton, change, blur to commit.
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
const spinbutton = screen.getByRole('spinbutton');
fireEvent.change(spinbutton, { target: { value: '95' } });
fireEvent.blur(spinbutton);
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
expect(masthead.last?.[0]).toMatchObject({ label: 'EDITED', value: '1 pending', tone: 'warn' });
expect(save).not.toBeDisabled();
@@ -109,7 +117,10 @@ 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')[1]); // (index 0 is host_alerts_enabled)
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
const spinbutton = screen.getByRole('spinbutton');
fireEvent.change(spinbutton, { target: { value: '95' } });
fireEvent.blur(spinbutton);
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
fireEvent.click(save);
@@ -133,17 +144,20 @@ describe('settings dirty reconcile on save', () => {
render(<HostAlertsSection onDirtyChange={onDirty} />);
const save = await screen.findByRole('button', { name: /save alerts/i });
// Change field A and submit (PATCH now pending).
fireEvent.click(screen.getAllByRole('switch')[1]); // global_crash (index 0 is host_alerts_enabled)
// Change field A (CPU limit via NumberChip) and submit (PATCH now pending).
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
const spinbuttonA = screen.getByRole('spinbutton');
fireEvent.change(spinbuttonA, { target: { value: '95' } });
fireEvent.blur(spinbuttonA);
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')[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);
// Change field B (host_alerts_enabled master toggle) while the save is still in flight.
const masterToggle = screen.getAllByRole('switch')[0]; // host_alerts_enabled (only switch left)
const toggleBefore = masterToggle.getAttribute('aria-checked');
fireEvent.click(masterToggle);
expect(masterToggle.getAttribute('aria-checked')).not.toBe(toggleBefore);
// Resolve the save: only the submitted snapshot becomes the baseline.
await act(async () => {
@@ -151,7 +165,7 @@ describe('settings dirty reconcile on save', () => {
});
// The later edit survives and keeps the section dirty/retryable.
expect(healthGate.getAttribute('aria-checked')).not.toBe(healthBefore);
expect(masterToggle.getAttribute('aria-checked')).not.toBe(toggleBefore);
await waitFor(() => expect(lastDirty(onDirty)).toBe(true));
expect(save).not.toBeDisabled();
});
@@ -170,7 +184,18 @@ 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')[1]), // global_crash (0=host_alerts_enabled)
// NumberChip: click chip button, change spinbutton, blur to commit
edit: () => {
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '95' } });
fireEvent.blur(screen.getByRole('spinbutton'));
},
},
{
name: 'ContainerAlertsSection',
render: onDirty => render(<ContainerAlertsSection onDirtyChange={onDirty} />),
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getByRole('switch')),
},
{
name: 'DockerStorageSection',
@@ -196,6 +221,12 @@ describe('every migrated section clears its dirty flag on save', () => {
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getByRole('switch')),
},
{
name: 'StacksSection',
render: onDirty => render(<StacksSection onDirtyChange={onDirty} />),
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getAllByRole('switch')[0]),
},
];
for (const c of cases) {
@@ -1,22 +1,46 @@
/**
* Guards the move of the stack-workflow controls out of Appearance into Stacks.
* Guards the move of the stack-workflow controls out of Appearance into Stacks,
* and the addition of the Deploy Guardrails node-scoped backend settings.
*
* The three controls (Deploy progress, Progress style, Diff preview before save)
* are browser-local localStorage preferences. Moving the JSX must not change the
* storage keys they write, so the deploy/editor consumers keep reading the same
* values. These tests assert the controls render in Stacks, still flip the same
* keys, and no longer render in Appearance.
* The three Workflow controls (Deploy progress, Progress style, Diff preview before
* save) are browser-local localStorage preferences. The three Deploy Guardrails
* controls (Observe health after updates, Observation window, Block deploy on
* missing required env vars) are node-scoped backend settings fetched and saved
* via /api/settings.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { StacksSection } from '../StacksSection';
import { AppearanceSection } from '../AppearanceSection';
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
import { DEPLOY_FEEDBACK_STYLE_KEY } from '@/hooks/use-deploy-feedback-style';
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const useAuthMock = vi.fn(() => ({ isAdmin: true }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
import { apiFetch } from '@/lib/api';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const FULL_SETTINGS: Record<string, string> = {
health_gate_enabled: '1',
health_gate_window_seconds: '90',
env_block_deploy_on_missing_required: '0',
};
beforeEach(() => {
window.localStorage.clear();
mockedFetch.mockReset();
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) });
useAuthMock.mockReturnValue({ isAdmin: true });
});
afterEach(() => {
@@ -24,16 +48,19 @@ afterEach(() => {
});
describe('StacksSection', () => {
it('renders the three workflow controls (Progress style while deploy progress is on)', () => {
it('renders the three workflow controls (Progress style while deploy progress is on)', async () => {
render(<StacksSection />);
expect(screen.getByText('Deploy progress')).toBeInTheDocument();
// Deploy progress defaults on, so Progress style is visible.
expect(screen.getByText('Progress style')).toBeInTheDocument();
expect(screen.getByText('Diff preview before save')).toBeInTheDocument();
// Wait for guardrails to load so the test doesn't leave a hanging update.
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
});
it('flips the deploy-progress key and hides Progress style when disabled', () => {
it('flips the deploy-progress key and hides Progress style when disabled', async () => {
const { container } = render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
const toggle = container.querySelector('#deploy-feedback') as HTMLElement;
// Default on => no stored value yet.
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBeNull();
@@ -43,18 +70,20 @@ describe('StacksSection', () => {
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
});
it('round-trips the progress-style key between Inline and Modal', () => {
it('round-trips the progress-style key between Inline and Modal', async () => {
render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
fireEvent.click(screen.getByRole('radio', { name: 'Inline' }));
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('inline');
fireEvent.click(screen.getByRole('radio', { name: 'Modal' }));
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('modal');
});
it('hydrates each control from its stored value (read path survives the move)', () => {
it('hydrates each control from its stored value (read path survives the move)', async () => {
window.localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
window.localStorage.setItem(COMPOSE_DIFF_PREVIEW_KEY, 'true');
const { container } = render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
// Deploy progress reads its stored 'false': checkbox unchecked, Progress style hidden.
expect(container.querySelector('#deploy-feedback')?.getAttribute('aria-checked')).toBe('false');
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
@@ -62,13 +91,51 @@ describe('StacksSection', () => {
expect(container.querySelector('#compose-diff-preview')?.getAttribute('aria-checked')).toBe('true');
});
it('flips the diff-preview key when enabled', () => {
it('flips the diff-preview key when enabled', async () => {
const { container } = render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
const toggle = container.querySelector('#compose-diff-preview') as HTMLElement;
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBeNull();
fireEvent.click(toggle);
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBe('true');
});
it('renders the Deploy Guardrails subsection with three controls', async () => {
render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
expect(screen.getByText('Observe health after updates')).toBeInTheDocument();
expect(screen.getByText('Observation window')).toBeInTheDocument();
expect(screen.getByText('Block deploy on missing required env vars')).toBeInTheDocument();
expect(screen.getByText('Save settings')).toBeInTheDocument();
});
it('disables guardrails for non-admin while Workflow controls remain enabled', async () => {
useAuthMock.mockReturnValue({ isAdmin: false });
render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
// The guardrail subsection is wrapped in a disabled fieldset; the workflow
// controls are not. At least one disabled fieldset should exist.
const disabledFieldsets = document.querySelectorAll('fieldset[disabled]');
expect(disabledFieldsets.length).toBeGreaterThan(0);
// Workflow controls: the deploy-progress checkbox should still be clickable
// (its fieldset is NOT disabled).
const deployCheckbox = document.querySelector('#deploy-feedback') as HTMLElement;
expect(deployCheckbox).not.toBeNull();
fireEvent.click(deployCheckbox);
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBe('false');
});
it('shows the workflow browser-local footer and the deploy guardrails node kicker', async () => {
render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
// Workflow: browser-local
expect(screen.getByText(/saved to this browser only/)).toBeInTheDocument();
// The SettingsSection headers carry the kicker text.
expect(screen.getByText('this browser')).toBeInTheDocument();
expect(screen.getByText('this node')).toBeInTheDocument();
});
});
describe('AppearanceSection no longer owns stack-workflow controls', () => {
@@ -45,6 +45,14 @@ describe('settings registry', () => {
expect(hostAlerts?.scope).toBe('node');
expect(dockerStorage?.scope).toBe('node');
expect(fleetMesh?.scope).toBe('node');
const containerAlerts = SETTINGS_ITEMS.find(i => i.id === 'container-alerts');
expect(containerAlerts?.group).toBe('monitoring');
expect(containerAlerts?.scope).toBe('node');
expect(containerAlerts?.tier).toBeNull();
for (const term of ['crash', 'oom', 'healthcheck', 'container']) {
expect(containerAlerts?.keywords, `keyword ${term}`).toContain(term);
}
});
it('gates the Fleet Mesh section to admins so the sidebar entry and panel both hide', () => {
@@ -90,14 +98,17 @@ describe('settings registry', () => {
const stacks = SETTINGS_ITEMS.find(i => i.id === 'stacks');
expect(stacks?.group).toBe('infrastructure');
expect(stacks?.tier).toBeNull();
for (const term of ['stack', 'deploy', 'progress', 'diff', 'save']) {
for (const term of ['stack', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'diff', 'save']) {
expect(stacks?.keywords, `keyword ${term}`).toContain(term);
}
});
it('scopes the browser-local sections (Appearance, Stacks) to the browser', () => {
it('scopes the browser-local sections to the browser', () => {
// Appearance is the only remaining browser-local section. Stacks moved to
// node scope when Deploy Guardrails (backend settings) were added to it
// alongside the existing browser-local Workflow controls.
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('node');
});
it('exposes the Calm/Signature appearance keywords for search', () => {
@@ -4,6 +4,7 @@ export { AccountSection } from './AccountSection';
export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { HostAlertsSection } from './HostAlertsSection';
export { ContainerAlertsSection } from './ContainerAlertsSection';
export { DockerStorageSection } from './DockerStorageSection';
export { UpdatesSection } from './UpdatesSection';
export { FleetMeshSection } from './FleetMeshSection';
+15 -6
View File
@@ -166,18 +166,27 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'stacks',
group: 'infrastructure',
label: 'Stacks',
description: 'Stack editor and lifecycle workflow preferences saved to this browser.',
keywords: ['stack', 'compose', 'deploy', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
description: 'Stack editor, lifecycle workflow preferences, and deploy guardrails.',
keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
tier: null,
scope: 'browser',
scope: 'node',
},
// Monitoring
{
id: 'host-alerts',
group: 'monitoring',
label: 'Host Alerts',
description: 'Alert thresholds for host CPU, RAM, and disk, plus suppression cadence and container crash capture.',
keywords: ['cpu', 'ram', 'disk', 'thresholds', 'alerts', 'suppression', 'crash', 'host', 'limits'],
description: 'Alert thresholds for host CPU, RAM, and disk, plus suppression cadence.',
keywords: ['cpu', 'ram', 'disk', 'thresholds', 'alerts', 'suppression', 'host', 'limits'],
tier: null,
scope: 'node',
},
{
id: 'container-alerts',
group: 'monitoring',
label: 'Container Alerts',
description: 'Crash, OOM, and healthcheck alert behavior for every managed container on this node.',
keywords: ['crash', 'oom', 'healthcheck', 'health', 'container', 'exit', 'alert', 'auto-heal'],
tier: null,
scope: 'node',
},
@@ -318,7 +327,7 @@ export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): bo
/**
* The masthead SCOPE value for a non-node section. Browser-local sections
* (Appearance, Stacks) persist to this browser's localStorage and read as
* (Appearance) persist to this browser's localStorage and read as
* browser regardless of their group; the signed-in Account is operator-scoped;
* Access sections (license, users, sso, api-tokens) are instance-global, so
* they read as global like every other non-node group. Node-scoped sections
@@ -54,6 +54,7 @@ export type SectionId =
| 'registries'
| 'labels'
| 'host-alerts'
| 'container-alerts'
| 'docker-storage'
| 'image-updates'
| 'fleet-mesh'