mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user