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
+5 -5
View File
@@ -367,15 +367,15 @@ A separate rate limit applies to crash and health alerts only: 20 emits per 60-s
## Crash detection toggle
The global crash-capture switch lives under **Settings · Monitoring · Host Alerts**.
The global crash-capture switch lives under **Settings · Monitoring · Container Alerts**.
<Frame>
<img src="/images/alerts-notifications/system-crash-toggle.png" alt="Settings · Monitoring · Host Alerts panel showing a HOST THRESHOLDS section with CPU limit, RAM limit, and Disk limit, then a CRASH CAPTURE section with a Global crash capture toggle in the ON state with the helper line 'Watch every managed container for unexpected exits.'" />
<img src="/images/alerts-notifications/system-crash-toggle.png" alt="Settings · Monitoring · Container Alerts panel showing a Container crash & health alerts toggle in the ON state with the helper line about unexpected exits, OOM kills, and healthcheck failures." />
</Frame>
The **Global crash capture** toggle controls whether `DockerEventService` raises crash, OOM, and healthcheck alerts on the active node. Helper text: `Watch every managed container for unexpected exits.` Defaults to on; if the database read fails, Sencho falls back to default-deny so the system never leaks alerts you cannot turn off.
The **Container crash & health alerts** toggle controls whether `DockerEventService` raises crash, OOM, and healthcheck alerts on the active node. Helper text: `Send alerts for unexpected container exits, OOM kills, and Docker healthcheck failures. Auto-Heal can still observe crash signals independently.` Defaults to on; if the database read fails, Sencho falls back to default-deny so the system never leaks alerts you cannot turn off.
The **Host Alerts** panel also carries the **Host thresholds** rows (CPU limit, RAM limit, Disk limit, all expressed as percent) that drive the host-level monitor warnings. The **Janitor threshold** (in GiB) that drives the unused-Docker-data alert lives in the **Docker & Storage** panel.
The **Host Alerts** panel carries the **Host thresholds** rows (CPU limit, RAM limit, Disk limit, all expressed as percent) that drive the host-level monitor warnings. Container crash and health alerts live in the **Container Alerts** panel. The **Janitor threshold** (in GiB) that drives the unused-Docker-data alert lives in the **Docker & Storage** panel.
## Refresh cadence
@@ -410,7 +410,7 @@ Switching the active node tears down per-stack rule editors and reloads channel
When the daemon disconnects and reconnects, Sencho snapshots every container at the moment of reconnect. If at least 20% exited during the gap, the service emits a single `info`/`system` summary `Docker daemon interruption detected: N containers exited during connection gap.` instead of one crash alert per container. Below the threshold, every gap exit is classified individually.
</Accordion>
<Accordion title="Crash alerts stopped arriving and nothing else looks wrong">
Check **Settings · Monitoring · Host Alerts · Global crash capture**. The toggle is the master switch for the Docker event service. The panel cache reads the database every 500 ms; if the database read errors, Sencho defaults the toggle to off so the failure mode is silent rather than spammy. Repair the toggle, save, and the next Docker event reaches the dispatcher.
Check **Settings · Monitoring · Container Alerts · Container crash & health alerts**. The toggle is the master switch for the Docker event service. The panel cache reads the database every 500 ms; if the database read errors, Sencho defaults the toggle to off so the failure mode is silent rather than spammy. Repair the toggle, save, and the next Docker event reaches the dispatcher.
</Accordion>
<Accordion title="A burst of crashes happened but I only see about twenty alerts">
Sencho rate-limits crash and health dispatches to 20 emits per rolling 60-second window per node, then emits a single `warning`/`monitor_alert` roll-up `N additional containers crashed in the last minute.` once the window closes. Every alert is still persisted to `notification_history` and visible in the bell up to the 100-row per-node cap; only the channel fanout is throttled.
+1 -1
View File
@@ -46,7 +46,7 @@ When a service declares an `env_file:` that does not exist in the stack director
A `${VAR:?message}` reference tells Compose the variable is required: the deploy fails if it is unset or empty. By default Sencho surfaces that as an advisory finding and lets Compose report it at deploy time.
If you would rather fail fast with a clear message before anything runs, turn on **Block deploy on missing required env vars** under **Settings → Host alerts → Deploy guardrails**. With it on, a deploy or update is refused up front when a required variable is unset or empty, before any backup, image pull, or container change happens. It is off by default, applies to the node you set it on, and requires an admin to change.
If you would rather fail fast with a clear message before anything runs, turn on **Block deploy on missing required env vars** under **Settings → Infrastructure → Stacks → Deploy Guardrails**. With it on, a deploy or update is refused up front when a required variable is unset or empty, before any backup, image pull, or container change happens. It is off by default, applies to the node you set it on, and requires an admin to change.
## Opening the inventory
+3 -3
View File
@@ -67,13 +67,13 @@ The gate also runs for updates you did not click: scheduled image updates, webho
### Tuning or disabling the gate
Open **Settings > Host Alerts > Update health gate** on the node you want to configure:
Open **Settings > Infrastructure > Stacks > Deploy Guardrails** on the node you want to configure:
- **Observe health after updates** turns the gate on or off for that node. On by default; turning it off only stops the observation and its timeline events, never the update itself.
- **Observation window** sets how long containers are watched, from 15 to 600 seconds. The default is 90 seconds; raise it for stacks that take a while to settle, since a healthcheck still starting at the end of the window records an unknown verdict instead of a pass.
<Frame>
<img src="/images/health-gated-updates/settings.png" alt="Host Alerts settings page with the Update health gate group, showing the Observe health after updates toggle and the Observation window field" />
<img src="/images/health-gated-updates/settings.png" alt="Stacks settings page with the Deploy Guardrails subsection, showing the Observe health after updates toggle and the Observation window field" />
</Frame>
## Rollback readiness
@@ -102,7 +102,7 @@ When a deploy or update fails, Sencho classifies the failure from the compose ou
Unknown means one of the verdict-affecting signals could not be verified, most often because Docker or the node did not answer in time. The update path is never blocked by an unknown verdict; check the node's connection if it persists, and proceed when you are confident in the stack's state.
</Accordion>
<Accordion title="The health gate failed but my app seems fine">
The gate fails on the first clear problem it observes: a container exit, an unhealthy healthcheck, a restart loop, or a container disappearing mid-window. Check the named container's logs from the stack page. If the container legitimately restarts during startup (a migration step, for example), raise the observation window under **Settings > Host Alerts > Update health gate** so the gate watches past the settle period.
The gate fails on the first clear problem it observes: a container exit, an unhealthy healthcheck, a restart loop, or a container disappearing mid-window. Check the named container's logs from the stack page. If the container legitimately restarts during startup (a migration step, for example), raise the observation window under **Settings > Infrastructure > Stacks > Deploy Guardrails** so the gate watches past the settle period.
</Accordion>
<Accordion title="The health gate result is unknown">
Unknown means the observation could not finish: Sencho restarted mid-window, a newer update superseded the observation, Docker became unreachable, a healthcheck was still starting when the window ended, or no containers appeared to observe. The stack timeline records the reason. Check the containers directly; an unknown verdict makes no claim either way.
+1 -1
View File
@@ -185,7 +185,7 @@ When you select a remote node in the switcher, the Settings hub filters to the p
| Panel | Scope | Notes |
|-------|:-----:|-------|
| Appearance | Per browser | Theme and density preferences are stored in your browser, not on the node. |
| Host Alerts | Per node | Host CPU, RAM, disk, and crash thresholds for the selected node. |
| Host Alerts | Per node | Host CPU, RAM, and disk thresholds for the selected node. |
| Channels | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
| Labels | Per node | Stack and container label palettes. |
| Vulnerability Scanning | Per node | Trivy install state and scanner readiness for the selected node. |
+35 -13
View File
@@ -16,7 +16,7 @@ Open the Settings Hub by clicking the **Profile** icon in the top bar and select
| **Personal** | Account, Appearance |
| **Access** | License, Users, SSO, API Tokens |
| **Infrastructure** | Nodes, Fleet Mesh, Registries, Cloud Backup, App Store, Stacks |
| **Monitoring** | Host Alerts, Docker & Storage |
| **Monitoring** | Host Alerts, Container Alerts, Docker & Storage |
| **Notifications** | Channels, Notification Routing |
| **Automation** | Webhooks |
| **Organization** | Labels |
@@ -33,7 +33,7 @@ Every section renders inside the same masthead-and-sidebar layout. The masthead
| Pill | Meaning |
|------|---------|
| **SCOPE** `operator` / `browser` / `global` | Setting applies to your account (`operator`), to this browser only (`browser`, for browser-local sections such as Appearance and Stacks), or to the whole instance (`global`, every other non-node group) |
| **SCOPE** `operator` / `browser` / `global` | Setting applies to your account (`operator`), to this browser only (`browser`, for browser-local sections such as Appearance), or to the whole instance (`global`, every other non-node group) |
| **NODE** `<node name>` | Setting is per-node and is currently being edited against this node |
| **EDITED** `<count>` pending / `saved` | The current section has unsaved changes |
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) |
@@ -225,14 +225,12 @@ See [API Tokens](/features/api-tokens) for the full walkthrough.
**Scope:** Per-node (applies to the currently selected node)
Configure the host resource thresholds that trigger warnings, the suppression cadence for repeated alerts, and whether unexpected container exits raise a crash alert. These are alerting thresholds, not enforcement: Sencho warns when a metric crosses a line, it never throttles or kills containers. The masthead **NODE** pill names which node you are configuring; the **EDITED** pill counts unsaved changes.
Configure the host resource thresholds that trigger warnings and the suppression cadence for repeated alerts. These are alerting thresholds, not enforcement: Sencho warns when a metric crosses a line, it never throttles or kills containers. The masthead **NODE** pill names which node you are configuring; the **EDITED** pill counts unsaved changes.
<Frame>
<img src="/images/settings/settings-host-alerts.png" alt="Host Alerts section showing the Host thresholds and Crash capture subsections" />
<img src="/images/settings/settings-host-alerts.png" alt="Host Alerts section showing the Host thresholds subsection with the master toggle and CPU, RAM, Disk, and Alert suppression controls" />
</Frame>
### Host thresholds
| 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. |
@@ -241,13 +239,21 @@ Configure the host resource thresholds that trigger warnings, the suppression ca
| **Disk limit** | 90% | Low free space slows image pulls and backups. |
| **Alert suppression** | 60 min | How long to wait before resending a host alert while the metric stays over threshold. The follow-up message includes a count of suppressed cycles. Range 1 to 1,440 minutes. |
### Crash capture
Click **Save alerts** to apply.
---
## Container Alerts
**Scope:** Per-node (applies to the currently selected node)
Controls whether Sencho watches every managed container on this node for unexpected exits, OOM kills, and Docker healthcheck failures, and dispatches alerts for each. Auto-Heal observes crash signals independently regardless of this toggle, so turning it off stops alerts but does not stop Auto-Heal.
| Setting | Default | Description |
|---------|---------|-------------|
| **Global crash capture** | On | Watch every managed container for unexpected exits. When on, Sencho sends a notification whenever a container exits with a non-zero status outside a planned stop. |
| **Container crash & health alerts** | On | Send alerts for unexpected container exits, OOM kills, and Docker healthcheck failures. Auto-Heal can still observe crash signals independently. |
Click **Save alerts** to apply.
Click **Save settings** to apply.
---
@@ -487,11 +493,13 @@ See [App Store](/features/app-store#custom-template-registry) for more on custom
## Stacks
Stack editor, lifecycle workflow preferences, and deploy guardrails. The Workflow controls are browser-local (each browser remembers its own choices). The Deploy Guardrails are node-scoped backend settings. The masthead shows the **NODE** pill for this section.
### Workflow
**Scope:** This browser (preferences are saved to local storage)
Stack editor and lifecycle workflow preferences. Each browser remembers its own choices, so they follow you across tabs but never change what other operators see. The masthead reads `SCOPE browser`.
### Deploy progress
#### Deploy progress
Sencho streams live output whenever you deploy, restart, update, install, or run a Git operation. It is on by default; turn it off to run operations without it. When it is on, **Progress style** chooses how it appears: **Modal** (a centered overlay that closes automatically on success or stays open on failure) or **Inline** (a quiet status band on the stack detail). See [Deploy Progress](/features/deploy-progress) for the full reference.
@@ -500,7 +508,7 @@ Sencho streams live output whenever you deploy, restart, update, install, or run
| **Enabled** (default) | A progress surface (the Modal overlay or the Inline band, per Progress style) shows for every long-running operation |
| **Disabled** | Operations run without it; results surface via toast notifications, and a failed operation still shows recovery actions on the stack page |
### Diff preview before save
#### Diff preview before save
When enabled, clicking **Save & Deploy** or **Save Only** in the compose or env editor opens a side-by-side diff modal before writing anything to disk. The left pane shows the current on-disk content; the right pane shows your unsaved edits, with additions highlighted green and removals highlighted red.
@@ -511,6 +519,20 @@ When enabled, clicking **Save & Deploy** or **Save Only** in the compose or env
If there are no unsaved changes the modal is skipped and the save proceeds immediately. See [Diff preview before save](/features/editor#diff-preview-before-save) in the Editor guide for the full workflow.
### Deploy Guardrails
**Scope:** Per-node (saved on the active node, admin-only)
Node-level safety checks and post-deploy observation used during stack deploys and updates.
| Setting | Default | Description |
|---------|---------|-------------|
| **Observe health after updates** | On | 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. |
| **Observation window** | 90 s | How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Range 15 to 600 seconds. |
| **Block deploy on missing required env vars** | Off | 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. |
Click **Save settings** to apply.
---
## Support
@@ -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'