feat(settings): reorganize the settings hub into domain groups (#1321)

* refactor(settings): split System Limits and regroup the hub

System Limits had grown into a grab-bag of host alert thresholds, Docker
cleanup, and mesh data-plane controls under one mislabeled section. Split it
into Host Alerts, Docker & Storage, and Fleet Mesh, and split Developer into
Developer Diagnostics and Data Retention. Reorganize the sidebar into ten
domain groups: Personal, Access, Infrastructure, Monitoring, Notifications,
Automation, Organization, Security, Operations, Help.

Each section now saves only its own keys, so a concurrent edit in one section
no longer clobbers another. Data Retention sends the audit-log window only on
a paid plan, matching the field's existing visibility, so a Community save no
longer fails on a key the operator cannot set. NumberChip moves to a shared
module and the toggle reuses the existing shared component. The /settings API
is unchanged.

* test(settings): cover registry structure and per-section save payloads

Add structural invariants for the ten-group registry (every item maps to a
real group, ids are unique, the System Limits and Developer splits land in the
right groups with the right gates, renamed labels and the Registries paid gate
hold) and per-section payload tests asserting each split section patches only
its own keys, including the Community path where Data Retention omits the paid
audit-log key.

* docs(settings): document the regrouped settings hub

Rewrite the settings reference for the ten-group layout, replace the System
Limits page with Host Alerts, Docker & Storage, and Fleet Mesh, and document
the prune-on-update, reclaimable-space banner, and mesh auto-recreate settings
that were previously undocumented. Update the Settings navigation breadcrumbs
across the feature docs and refresh the affected screenshots.

* fix(settings): show Access sections as instance-global, not operator-scoped

License, Users, SSO, and API Tokens are instance-global settings but the
masthead scope label rendered them as operator-scoped because it keyed off the
old Identity group. Only Personal sections (account, appearance) are
operator/browser-scoped now; everything else reads as global.

Also add a compile-time exhaustiveness guard to the section switch so a future
SectionId added without a matching case fails the build instead of silently
rendering a blank panel.

* docs(settings): remap remaining settings breadcrumbs to the new groups

Update the navigation breadcrumbs that still pointed at the removed Identity,
Alerts, and Advanced groups: API Tokens and Users now sit under Access, Webhooks
under Automation, Labels under Organization, App Store under Infrastructure,
Appearance under Personal, and scan policies under Security > Vulnerability
Scanning. Correct the settings reference scope note so Access reads as global.

* docs(settings): remap renamed-section breadcrumbs across feature docs

Sweep every feature, operations, getting-started, and reference page for
navigation paths that still named the renamed settings sections, and point them
at the current ones: Security becomes Security > Vulnerability Scanning,
Notifications becomes Notifications > Channels, Routing becomes Notifications >
Notification Routing, and Developer becomes Operations > Developer Diagnostics
(with its retention windows under Operations > Data Retention). App Store moves
under Infrastructure and the four-group overview in the getting-started intro is
rewritten to the ten groups. Separators each page already used are preserved.
This commit is contained in:
Anso
2026-06-05 23:01:37 -04:00
committed by GitHub
parent f7f3afe05a
commit ce08a593d7
53 changed files with 1404 additions and 689 deletions
@@ -158,12 +158,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Auto-heal policies"
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
<Row
label="Auto-update schedules"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
{!automation.webhooks.locked && (
<Row
@@ -176,7 +176,7 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Scheduled tasks"
value={formatCount(automation.scheduledTasks.enabled, 'active')}
onClick={open('system')}
onClick={open('host-alerts')}
/>
)}
@@ -206,12 +206,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Alert thresholds"
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
<Row
label="Crash detection"
value={thresholds.globalCrash ? 'On' : 'Off'}
onClick={open('system')}
onClick={open('host-alerts')}
/>
</div>
</CardContent>
@@ -0,0 +1,236 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Input } from '@/components/ui/input';
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 { useLicense } from '@/context/LicenseContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
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';
interface DataRetentionSectionProps {
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" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type DataRetentionFields = Pick<PatchableSettings, 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
const DEFAULT_DATA_RETENTION: DataRetentionFields = {
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit,
};
export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const serverSettingsRef = useRef<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.metrics_retention_hours !== baseline.metrics_retention_hours) n++;
if (settings.log_retention_days !== baseline.log_retention_days) n++;
if (settings.audit_retention_days !== baseline.audit_retention_days) n++;
if (settings.scan_history_per_image_limit !== baseline.scan_history_per_image_limit) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
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: DataRetentionFields = {
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch data retention settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof DataRetentionFields>(key: K, value: DataRetentionFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const payload: DataRetentionFields = {
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
scan_history_per_image_limit: settings.scan_history_per_image_limit,
};
// audit_retention_days is a paid-only key the backend rejects from a
// Community operator. The field renders only when isPaid, so include it
// in the save only then; otherwise a Community save would 403 on a key
// the operator cannot edit and never sees.
if (isPaid) {
payload.audit_retention_days = settings.audit_retention_days;
}
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Data retention saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
} 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="Retention windows">
<SettingsField
label="Container metrics"
helper="How long to keep per-container CPU, RAM, and network history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">hrs</span>
</div>
</SettingsField>
<SettingsField
label="Notification log"
helper="How long to keep alert and notification history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
<SettingsField
label="Scan history per image"
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={1000}
value={settings.scan_history_per_image_limit}
onChange={(e) => onSettingChange('scan_history_per_image_limit', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">scans</span>
</div>
</SettingsField>
{isPaid && (
<SettingsField
label="Audit log"
helper="How long to keep audit trail entries."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</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>
);
}
@@ -1,9 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -25,25 +23,18 @@ function SectionSkeleton() {
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>
);
}
type DeveloperFields = Pick<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
type DeveloperFields = Pick<PatchableSettings, 'developer_mode'>;
const DEFAULT_DEVELOPER: DeveloperFields = {
developer_mode: DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit,
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DeveloperFields>({ ...DEFAULT_DEVELOPER });
@@ -51,12 +42,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const hasChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days ||
settings.scan_history_per_image_limit !== serverSettingsRef.current.scan_history_per_image_limit;
const hasChanges = settings.developer_mode !== serverSettingsRef.current.developer_mode;
useEffect(() => {
onDirtyChange?.(hasChanges);
@@ -82,10 +68,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -106,10 +88,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const saveSettings = async () => {
const payload = {
developer_mode: settings.developer_mode,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
scan_history_per_image_limit: settings.scan_history_per_image_limit,
};
setIsSaving(true);
try {
@@ -151,78 +129,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsSection title="Data retention">
<SettingsField
label="Container metrics"
helper="How long to keep per-container CPU, RAM, and network history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">hrs</span>
</div>
</SettingsField>
<SettingsField
label="Notification log"
helper="How long to keep alert and notification history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
<SettingsField
label="Scan history per image"
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={1000}
value={settings.scan_history_per_image_limit}
onChange={(e) => onSettingChange('scan_history_per_image_limit', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">scans</span>
</div>
</SettingsField>
{isPaid && (
<SettingsField
label="Audit log"
helper="How long to keep audit trail entries."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
)}
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
@@ -0,0 +1,180 @@
import { useState, useRef, useEffect, useMemo } from 'react';
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 { 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 { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
interface DockerStorageSectionProps {
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" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type DockerStorageFields = Pick<PatchableSettings, 'docker_janitor_gb' | 'prune_on_update' | 'reclaim_hero'>;
const DEFAULT_DOCKER_STORAGE: DockerStorageFields = {
docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: DEFAULT_SETTINGS.reclaim_hero,
};
export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const serverSettingsRef = useRef<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.docker_janitor_gb !== baseline.docker_janitor_gb) n++;
if (settings.prune_on_update !== baseline.prune_on_update) n++;
if (settings.reclaim_hero !== baseline.reclaim_hero) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
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: DockerStorageFields = {
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch Docker & storage settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof DockerStorageFields>(key: K, value: DockerStorageFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Docker & storage 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="Storage alerts">
<SettingsField
label="Janitor threshold"
helper="Alert when reclaimable Docker data exceeds this size."
>
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
</SettingsField>
<SettingsField
label="Show reclaimable-space banner"
helper="Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. On by default."
>
<TogglePill
checked={settings.reclaim_hero === '1'}
onChange={(next) => onSettingChange('reclaim_hero', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Image cleanup">
<SettingsField
label="Prune dangling images after updates"
helper="When an update finishes, remove the node's dangling (untagged) image layers, including the one the update just orphaned. On by default; turn it off to keep every old layer. Applies to stack updates and Sencho self-updates on this node."
>
<TogglePill
checked={settings.prune_on_update === '1'}
onChange={(next) => onSettingChange('prune_on_update', 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}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,146 @@
import { useState, useRef, useEffect, useMemo } from 'react';
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 { 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 { TogglePill } from '@/components/ui/toggle-pill';
interface FleetMeshSectionProps {
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 FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate'>;
const DEFAULT_FLEET_MESH: FleetMeshFields = {
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
};
export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
const serverSettingsRef = useRef<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
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: FleetMeshFields = {
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch fleet mesh settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof FleetMeshFields>(key: K, value: FleetMeshFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Mesh 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="Mesh data plane">
<SettingsField
label="Auto-recreate mesh network"
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
>
<TogglePill
checked={settings.mesh_auto_recreate === '1'}
onChange={(next) => onSettingChange('mesh_auto_recreate', 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}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,216 @@
import { useState, useRef, useEffect, useMemo } from 'react';
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 { 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 { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
interface HostAlertsSectionProps {
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" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash'>;
const DEFAULT_HOST_ALERTS: HostAlertFields = {
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
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,
};
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const serverSettingsRef = useRef<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.host_cpu_limit !== baseline.host_cpu_limit) n++;
if (settings.host_ram_limit !== baseline.host_ram_limit) n++;
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
if (settings.host_alert_suppression_mins !== baseline.host_alert_suppression_mins) n++;
if (settings.global_crash !== baseline.global_crash) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
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: HostAlertFields = {
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
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,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch host alert settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof HostAlertFields>(key: K, value: HostAlertFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Host alerts 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="Host thresholds">
<SettingsField
label="CPU limit"
helper="Alerts fire when host CPU utilization exceeds this percentage."
>
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="RAM limit"
helper="Swap is never acceptable. Set this below where the host begins paging."
>
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Disk limit"
helper="Low free space slows image pulls and backups."
>
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Alert suppression"
helper="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."
>
<NumberChip
value={settings.host_alert_suppression_mins || '60'}
onChange={(v) => onSettingChange('host_alert_suppression_mins', v)}
suffix="min"
min={1}
max={1440}
/>
</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>
<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 alerts'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -19,9 +19,12 @@ import {
AccountSection,
AppearanceSection,
LicenseSection,
SystemSection,
HostAlertsSection,
DockerStorageSection,
FleetMeshSection,
NotificationsSection,
DeveloperSection,
DataRetentionSection,
AppStoreSection,
SupportSection,
AboutSection,
@@ -191,19 +194,23 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'system': return <SystemSection onDirtyChange={(d) => handleDirtyChange('system', d)} />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => handleDirtyChange('host-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => handleDirtyChange('docker-storage', d)} />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => handleDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => handleDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return null;
// Exhaustiveness guard: a new SectionId without a case above fails tsc here.
default: return assertExhaustiveSection(safeSection);
}
// Section components close over isPaid for tier-gated branches; handleDirtyChange is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -301,8 +308,19 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
);
}
// Compile-time check that the section switch covers every SectionId. If the
// switch is ever reached at runtime (it should not be, since safeSection is a
// validated registry id), log the unhandled id and render nothing rather than crash.
function assertExhaustiveSection(section: never): null {
console.error('Unhandled settings section', section);
return null;
}
function scopeLabel(item: SettingsItemMeta): string {
if (item.group === 'identity') return 'operator';
// Personal sections (account, appearance) apply to the signed-in operator or
// this browser. Access sections (license, users, sso, api-tokens) are
// instance-global, so they read as global like every other non-node group.
if (item.group === 'personal') return 'operator';
return 'global';
}
@@ -0,0 +1,83 @@
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface NumberChipProps {
value: string;
onChange: (v: string) => void;
suffix: string;
min?: number;
max?: number;
step?: number;
warnOver?: number;
}
export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver }: NumberChipProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const startEdit = () => {
setDraft(value);
setEditing(true);
};
const commit = () => {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (trimmed !== '' && Number.isFinite(parsed)) {
let next = parsed;
if (typeof min === 'number') next = Math.max(min, next);
if (typeof max === 'number') next = Math.min(max, next);
onChange(String(next));
}
setEditing(false);
};
const numeric = Number(value);
const warn = typeof warnOver === 'number' && Number.isFinite(numeric) && numeric > warnOver;
const chipClass = cn(
'inline-flex items-baseline gap-1 rounded-md border px-2.5 py-1 font-mono text-sm tabular-nums tracking-tight transition-colors min-w-[78px] justify-end focus-within:ring-2 focus-within:ring-brand/50 focus-within:outline-none',
warn
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-card-border bg-card text-stat-value hover:border-brand/50',
);
if (editing) {
return (
<span className={chipClass}>
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
className="w-12 bg-transparent text-right outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-stat-subtitle">{suffix}</span>
</span>
);
}
return (
<button
type="button"
className={cn(chipClass, 'focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed')}
onClick={startEdit}
>
<span>{value || '0'}</span>
<span className="text-stat-subtitle">{suffix}</span>
</button>
);
}
@@ -1,386 +0,0 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
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';
interface SystemSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
interface NumberChipProps {
value: string;
onChange: (v: string) => void;
suffix: string;
min?: number;
max?: number;
step?: number;
warnOver?: number;
}
function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver }: NumberChipProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const startEdit = () => {
setDraft(value);
setEditing(true);
};
const commit = () => {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (trimmed !== '' && Number.isFinite(parsed)) {
let next = parsed;
if (typeof min === 'number') next = Math.max(min, next);
if (typeof max === 'number') next = Math.min(max, next);
onChange(String(next));
}
setEditing(false);
};
const numeric = Number(value);
const warn = typeof warnOver === 'number' && Number.isFinite(numeric) && numeric > warnOver;
const chipClass = cn(
'inline-flex items-baseline gap-1 rounded-md border px-2.5 py-1 font-mono text-sm tabular-nums tracking-tight transition-colors min-w-[78px] justify-end focus-within:ring-2 focus-within:ring-brand/50 focus-within:outline-none',
warn
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-card-border bg-card text-stat-value hover:border-brand/50',
);
if (editing) {
return (
<span className={chipClass}>
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
className="w-12 bg-transparent text-right outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-stat-subtitle">{suffix}</span>
</span>
);
}
return (
<button
type="button"
className={cn(chipClass, 'focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed')}
onClick={startEdit}
>
<span>{value || '0'}</span>
<span className="text-stat-subtitle">{suffix}</span>
</button>
);
}
interface TogglePillProps {
checked: boolean;
onChange: (next: boolean) => void;
}
function TogglePill({ checked, onChange }: TogglePillProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'inline-flex items-center justify-center rounded-md border px-2.5 py-1 font-mono text-xs uppercase tracking-[0.18em] transition-colors min-w-[60px] focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed',
checked
? 'border-success/30 bg-success/10 text-success hover:bg-success/15'
: 'border-card-border bg-card text-stat-subtitle hover:text-stat-value',
)}
>
{checked ? 'ON' : 'OFF'}
</button>
);
}
function SettingsSkeleton() {
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" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type SystemFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'docker_janitor_gb' | 'global_crash' | 'prune_on_update' | 'mesh_auto_recreate' | 'reclaim_hero'>;
const DEFAULT_SYSTEM: SystemFields = {
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
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,
docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: DEFAULT_SETTINGS.global_crash,
prune_on_update: DEFAULT_SETTINGS.prune_on_update,
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
reclaim_hero: DEFAULT_SETTINGS.reclaim_hero,
};
export function SystemSection({ onDirtyChange }: SystemSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<SystemFields>({ ...DEFAULT_SYSTEM });
const serverSettingsRef = useRef<SystemFields>({ ...DEFAULT_SYSTEM });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.host_cpu_limit !== baseline.host_cpu_limit) n++;
if (settings.host_ram_limit !== baseline.host_ram_limit) n++;
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
if (settings.host_alert_suppression_mins !== baseline.host_alert_suppression_mins) n++;
if (settings.docker_janitor_gb !== baseline.docker_janitor_gb) n++;
if (settings.global_crash !== baseline.global_crash) n++;
if (settings.prune_on_update !== baseline.prune_on_update) n++;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
if (settings.reclaim_hero !== baseline.reclaim_hero) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
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: SystemFields = {
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch system settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof SystemFields>(key: K, value: SystemFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('System limits saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SettingsSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Host thresholds">
<SettingsField
label="CPU limit"
helper="Alerts fire when host CPU utilization exceeds this percentage."
>
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="RAM limit"
helper="Swap is never acceptable. Set this below where the host begins paging."
>
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Disk limit"
helper="Low free space slows image pulls and backups."
>
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Alert suppression"
helper="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."
>
<NumberChip
value={settings.host_alert_suppression_mins || '60'}
onChange={(v) => onSettingChange('host_alert_suppression_mins', v)}
suffix="min"
min={1}
max={1440}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Docker hygiene">
<SettingsField
label="Janitor threshold"
helper="Alert when reclaimable Docker data exceeds this size."
>
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
</SettingsField>
<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>
<SettingsField
label="Prune dangling images after updates"
helper="When an update finishes, remove the node's dangling (untagged) image layers, including the one the update just orphaned. On by default; turn it off to keep every old layer. Applies to stack updates and Sencho self-updates on this node."
>
<TogglePill
checked={settings.prune_on_update === '1'}
onChange={(next) => onSettingChange('prune_on_update', next ? '1' : '0')}
/>
</SettingsField>
<SettingsField
label="Show reclaimable-space banner"
helper="Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. On by default."
>
<TogglePill
checked={settings.reclaim_hero === '1'}
onChange={(next) => onSettingChange('reclaim_hero', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
{/*
Mesh-data-plane recreate touches Docker (createNetwork +
connectContainerToNetwork) on the backend, which is admin-gated
by `requireAdmin` on the settings route. Hide the affordance
for non-admins so the toggle does not surface a 403 on save.
The rest of the System section stays visible because it
matches the existing pattern (host thresholds, janitor, alert
suppression all visible read-only to non-admins).
*/}
{isAdmin && (
<SettingsSection title="Mesh data plane">
<SettingsField
label="Auto-recreate mesh network"
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
>
<TogglePill
checked={settings.mesh_auto_recreate === '1'}
onChange={(next) => onSettingChange('mesh_auto_recreate', 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}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save limits'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,133 @@
/**
* The System Limits split is only correct if each successor section saves just
* its own keys. The old SystemSection PATCHed all nine keys at once; after the
* split a save from one section must not carry another section's keys, otherwise
* editing Host Alerts could clobber a concurrently-changed Docker setting.
*
* Each test loads a section, makes one change, saves, and asserts the PATCH body
* contains exactly that section's key set.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
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() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
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';
import { useLicense } from '@/context/LicenseContext';
import { HostAlertsSection } from '../HostAlertsSection';
import { DockerStorageSection } from '../DockerStorageSection';
import { FleetMeshSection } from '../FleetMeshSection';
import { DataRetentionSection } from '../DataRetentionSection';
import { DeveloperSection } from '../DeveloperSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedLicense = useLicense as unknown as ReturnType<typeof vi.fn>;
const FULL_SETTINGS: Record<string, string> = {
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
global_crash: '1',
docker_janitor_gb: '5',
prune_on_update: '1',
reclaim_hero: '1',
mesh_auto_recreate: '0',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
scan_history_per_image_limit: '50',
developer_mode: '0',
};
function patchedKeys(): string[] {
const patch = [...mockedFetch.mock.calls].reverse().find(c => c[1]?.method === 'PATCH');
if (!patch) throw new Error('expected a PATCH /settings call');
return Object.keys(JSON.parse(patch[1].body as string)).sort();
}
beforeEach(() => {
mockedFetch.mockReset();
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) });
mockedLicense.mockReturnValue({ isPaid: true });
});
describe('split section save payloads', () => {
it('HostAlertsSection patches only host alert keys', async () => {
render(<HostAlertsSection />);
const save = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getByRole('switch')); // global_crash
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'global_crash',
'host_alert_suppression_mins',
'host_cpu_limit',
'host_disk_limit',
'host_ram_limit',
]);
});
it('DockerStorageSection patches only docker and storage keys', async () => {
render(<DockerStorageSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getAllByRole('switch')[0]); // reclaim_hero
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']);
});
it('FleetMeshSection patches only the mesh key', async () => {
render(<FleetMeshSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // mesh_auto_recreate
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['mesh_auto_recreate']);
});
it('DataRetentionSection patches only retention keys, never developer_mode', async () => {
render(<DataRetentionSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '48' } }); // metrics window
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'audit_retention_days',
'log_retention_days',
'metrics_retention_hours',
'scan_history_per_image_limit',
]);
});
it('DataRetentionSection omits the paid audit_retention_days key for a Community operator', async () => {
mockedLicense.mockReturnValue({ isPaid: false });
render(<DataRetentionSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '48' } }); // metrics window
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
// audit_retention_days is paid-gated; sending it would 403 the whole save.
expect(patchedKeys()).toEqual([
'log_retention_days',
'metrics_retention_hours',
'scan_history_per_image_limit',
]);
});
it('DeveloperSection patches only developer_mode', async () => {
render(<DeveloperSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // developer_mode
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['developer_mode']);
});
});
@@ -0,0 +1,73 @@
/**
* Structural invariants for the settings registry after the hub reorganization.
*
* Guards the ten-group taxonomy and the System Limits split: every item lands in
* a real group, the three successor sections (Host Alerts, Docker & Storage,
* Fleet Mesh) exist with the right group/scope/gate, Developer is split from
* Data Retention, and the renamed labels are applied.
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_GROUPS, SETTINGS_ITEMS } from '../registry';
describe('settings registry', () => {
it('points every item at a defined group', () => {
const groupIds = new Set(SETTINGS_GROUPS.map(g => g.id));
for (const item of SETTINGS_ITEMS) {
expect(groupIds.has(item.group), `item ${item.id} -> group ${item.group}`).toBe(true);
}
});
it('gives every group at least one item', () => {
for (const group of SETTINGS_GROUPS) {
const count = SETTINGS_ITEMS.filter(i => i.group === group.id).length;
expect(count, `group ${group.id}`).toBeGreaterThan(0);
}
});
it('keeps item ids unique', () => {
const ids = SETTINGS_ITEMS.map(i => i.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('replaces System Limits with three focused sections', () => {
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'system')).toBe(false);
const hostAlerts = SETTINGS_ITEMS.find(i => i.id === 'host-alerts');
const dockerStorage = SETTINGS_ITEMS.find(i => i.id === 'docker-storage');
const fleetMesh = SETTINGS_ITEMS.find(i => i.id === 'fleet-mesh');
expect(hostAlerts?.group).toBe('monitoring');
expect(dockerStorage?.group).toBe('monitoring');
expect(fleetMesh?.group).toBe('infrastructure');
// All three edit the active instance's own settings through the proxy.
expect(hostAlerts?.scope).toBe('node');
expect(dockerStorage?.scope).toBe('node');
expect(fleetMesh?.scope).toBe('node');
});
it('gates the Fleet Mesh section to admins so the sidebar entry and panel both hide', () => {
const fleetMesh = SETTINGS_ITEMS.find(i => i.id === 'fleet-mesh');
expect(fleetMesh?.adminOnly).toBe(true);
});
it('splits Developer into Developer Diagnostics and Data Retention under Operations', () => {
const developer = SETTINGS_ITEMS.find(i => i.id === 'developer');
const dataRetention = SETTINGS_ITEMS.find(i => i.id === 'data-retention');
expect(developer?.label).toBe('Developer Diagnostics');
expect(developer?.group).toBe('operations');
expect(dataRetention?.group).toBe('operations');
});
it('applies the renamed section labels', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('notifications')?.label).toBe('Channels');
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
expect(byId.get('security')?.label).toBe('Vulnerability Scanning');
});
it('preserves the paid gate on Registries', () => {
const registries = SETTINGS_ITEMS.find(i => i.id === 'registries');
expect(registries?.tier).toBe('paid');
});
});
+4 -1
View File
@@ -3,9 +3,12 @@
export { AccountSection } from './AccountSection';
export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { SystemSection } from './SystemSection';
export { HostAlertsSection } from './HostAlertsSection';
export { DockerStorageSection } from './DockerStorageSection';
export { FleetMeshSection } from './FleetMeshSection';
export { NotificationsSection } from './NotificationsSection';
export { DeveloperSection } from './DeveloperSection';
export { DataRetentionSection } from './DataRetentionSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
+103 -49
View File
@@ -1,6 +1,16 @@
import type { SectionId } from './types';
export type SettingsGroupId = 'identity' | 'system' | 'alerts' | 'advanced';
export type SettingsGroupId =
| 'personal'
| 'access'
| 'infrastructure'
| 'monitoring'
| 'notifications'
| 'automation'
| 'organization'
| 'security'
| 'operations'
| 'help';
export interface SettingsGroupMeta {
id: SettingsGroupId;
@@ -10,10 +20,16 @@ export interface SettingsGroupMeta {
}
export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
{ id: 'identity', label: 'Identity', glyph: '\u25C8' },
{ id: 'system', label: 'System', kicker: 'node-scoped', glyph: '\u25C6' },
{ id: 'alerts', label: 'Alerts', glyph: '\u25C7' },
{ id: 'advanced', label: 'Advanced', glyph: '\u25C7' },
{ id: 'personal', label: 'Personal', glyph: '\u25C8' },
{ id: 'access', label: 'Access', glyph: '\u25C8' },
{ id: 'infrastructure', label: 'Infrastructure', glyph: '\u25C6' },
{ id: 'monitoring', label: 'Monitoring', kicker: 'node-scoped', glyph: '\u25C6' },
{ id: 'notifications', label: 'Notifications', glyph: '\u25C7' },
{ id: 'automation', label: 'Automation', glyph: '\u25C7' },
{ id: 'organization', label: 'Organization', glyph: '\u25C7' },
{ id: 'security', label: 'Security', glyph: '\u25C6' },
{ id: 'operations', label: 'Operations', glyph: '\u25C7' },
{ id: 'help', label: 'Help', glyph: '\u25C7' },
];
export type TierGate = 'paid' | null;
@@ -32,9 +48,10 @@ export interface SettingsItemMeta {
}
export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
// Personal
{
id: 'account',
group: 'identity',
group: 'personal',
label: 'Account',
description: 'Password, MFA, and session controls for the signed-in operator.',
keywords: ['password', 'mfa', 'two-factor', 'session', 'profile'],
@@ -44,16 +61,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'appearance',
group: 'identity',
group: 'personal',
label: 'Appearance',
description: 'Theme, accent, density, and display preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
tier: null,
scope: 'global',
},
// Access
{
id: 'license',
group: 'identity',
group: 'access',
label: 'License',
description: 'Activation key, plan tier, and seat allocation.',
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
@@ -63,7 +81,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'users',
group: 'identity',
group: 'access',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
@@ -74,7 +92,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'sso',
group: 'identity',
group: 'access',
label: 'SSO',
description: 'Single sign-on via SAML or OIDC identity providers.',
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
@@ -85,7 +103,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'api-tokens',
group: 'identity',
group: 'access',
label: 'API Tokens',
description: 'Long-lived bearer tokens for CI and scripts.',
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
@@ -94,18 +112,30 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Infrastructure
{
id: 'system',
group: 'system',
label: 'System Limits',
description: 'Threshold percentages for host CPU, RAM, disk, and crash-loop alerts.',
keywords: ['cpu', 'ram', 'disk', 'limits', 'thresholds', 'alerts'],
id: 'nodes',
group: 'infrastructure',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'fleet-mesh',
group: 'infrastructure',
label: 'Fleet Mesh',
description: 'Data-plane network behavior for the cross-node service mesh.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh'],
tier: null,
scope: 'node',
adminOnly: true,
},
{
id: 'registries',
group: 'system',
group: 'infrastructure',
label: 'Registries',
description: 'Private Docker registries and pull credentials.',
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
@@ -116,7 +146,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'cloud-backup',
group: 'system',
group: 'infrastructure',
label: 'Cloud Backup',
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
@@ -126,28 +156,47 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
hiddenOnRemote: true,
},
{
id: 'nodes',
group: 'system',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
id: 'app-store',
group: 'infrastructure',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
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'],
tier: null,
scope: 'node',
},
{
id: 'docker-storage',
group: 'monitoring',
label: 'Docker & Storage',
description: 'Reclaimable-space alerts and Docker image cleanup after updates.',
keywords: ['docker', 'janitor', 'prune', 'reclaim', 'storage', 'images', 'cleanup', 'dangling'],
tier: null,
scope: 'node',
},
// Notifications
{
id: 'notifications',
group: 'alerts',
label: 'Notifications',
description: 'In-app toasts and browser push for stack, container, and system events.',
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
group: 'notifications',
label: 'Channels',
description: 'Discord, Slack, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'webhook', 'channels', 'destinations', 'alerts'],
tier: null,
scope: 'node',
},
{
id: 'notification-routing',
group: 'alerts',
label: 'Routing',
group: 'notifications',
label: 'Notification Routing',
description: 'Rules that steer alerts to the right channel based on severity or label.',
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
tier: null,
@@ -155,9 +204,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Automation
{
id: 'webhooks',
group: 'alerts',
group: 'automation',
label: 'Webhooks',
description: 'Incoming HMAC-signed HTTP triggers that run stack actions from CI/CD pipelines.',
keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'],
@@ -165,46 +215,49 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
scope: 'global',
hiddenOnRemote: true,
},
// Organization
{
id: 'labels',
group: 'advanced',
group: 'organization',
label: 'Labels',
description: 'Per-node labels for stacks and containers.',
keywords: ['labels', 'tags', 'palette', 'organisation'],
tier: null,
scope: 'node',
},
// Security
{
id: 'security',
group: 'advanced',
label: 'Security',
group: 'security',
label: 'Vulnerability Scanning',
description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening', 'vulnerability', 'misconfig'],
tier: null,
scope: 'node',
adminOnly: true,
},
// Operations
{
id: 'developer',
group: 'advanced',
label: 'Developer',
description: 'Retention windows and debug modes.',
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
id: 'data-retention',
group: 'operations',
label: 'Data Retention',
description: 'How long to keep container metrics, notification logs, scan history, and audit entries.',
keywords: ['retention', 'metrics', 'logs', 'scans', 'audit', 'history', 'prune', 'window'],
tier: null,
scope: 'node',
},
{
id: 'app-store',
group: 'advanced',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
id: 'developer',
group: 'operations',
label: 'Developer Diagnostics',
description: 'Developer mode for real-time metrics streams and verbose debug diagnostics.',
keywords: ['developer', 'debug', 'diagnostics', 'metrics', 'verbose'],
tier: null,
scope: 'node',
},
{
id: 'recovery',
group: 'advanced',
group: 'operations',
label: 'Recovery',
description: 'System health snapshot, safe recovery actions, and emergency command-line reference.',
keywords: ['recovery', 'safe mode', 'diagnostics', 'health', 'emergency', 'cli', 'reset', 'backup', 'restore'],
@@ -213,9 +266,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Help
{
id: 'support',
group: 'advanced',
group: 'help',
label: 'Support',
description: 'Diagnostics bundle, docs links, and contact channels.',
keywords: ['help', 'diagnostics', 'bundle', 'docs', 'contact'],
@@ -224,7 +278,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'about',
group: 'advanced',
group: 'help',
label: 'About',
description: 'Build metadata, release notes, and licence attributions.',
keywords: ['version', 'build', 'release', 'attributions'],
+4 -1
View File
@@ -43,12 +43,15 @@ export type SectionId =
| 'api-tokens'
| 'registries'
| 'labels'
| 'system'
| 'host-alerts'
| 'docker-storage'
| 'fleet-mesh'
| 'notifications'
| 'webhooks'
| 'security'
| 'cloud-backup'
| 'developer'
| 'data-retention'
| 'nodes'
| 'app-store'
| 'notification-routing'