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
@@ -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}>