feat(settings): group sections, add ⌘K search, scope breadcrumb (#680)

* feat(settings): group sections, add ⌘K search, scope breadcrumb

Restructures the Settings Hub sidebar into four labelled groups
(Identity, System, Alerts, Advanced), adds a ⌘K command palette for
section search, and surfaces the active scope (global vs node-scoped)
in the content breadcrumb.

- New `settings/registry.ts` centralises group/item metadata, tier gates,
  glyph assignments, visibility rules, and keyword hints consumed by both
  the sidebar and the command palette
- Cyan 2px left rail + gradient on the active sidebar item; mono-uppercase
  group headers; tier chips inline for locked items
- Scoped ⌘K handler via onKeyDownCapture on DialogContent so the hub no
  longer hijacks the global sidebar shortcut while open
- ScrollArea gains an opt-in `block` prop so the Nodes management table
  can overflow horizontally without Radix's default `display: table`
  wrapper clipping action buttons
- Docs reference updated with the grouped sidebar, scope breadcrumb, and
  ⌘K walkthrough plus refreshed screenshots

* refactor(settings): drop duplicate section headers, redesign system limits, always-visible tier chips

- Remove redundant section titles in every settings page; the dialog header now owns the title and description
- Rework System Limits into a compact row panel with inline-edit chips (warn state, focus ring) and an ON/OFF toggle pill
- Show tier chips on sidebar and command palette whether locked or unlocked, so Skipper/Admiral scope is always legible
- Keep right-aligned action buttons on pages that had a title+button header (Users, Labels, Nodes, API Tokens, Registries)

* fix(settings): seed NumberChip draft on edit instead of via effect

ESLint rule react-hooks/set-state-in-effect flagged the sync effect that
mirrored the external value into local draft state. Replace it with a
startEdit handler that seeds draft from value at click time, so the
button path always reads value directly and no cascading render is
triggered on prop change.

* fix(settings): restore heading role and clean sidebar accessible names

- Wrap the settings dialog title in an h2 so screen readers and E2E locators see a heading again after the in-section headers were removed
- Mark the sidebar glyph aria-hidden so the button's accessible name is just the item label (fixes anchored name matchers)
- Align the MFA E2E helper with the renamed Account section heading
This commit is contained in:
Anso
2026-04-18 16:17:24 -04:00
committed by GitHub
parent ec7620675e
commit 0bf061a745
26 changed files with 796 additions and 449 deletions
@@ -7,11 +7,6 @@ export function AboutSection() {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">About Sencho</h3>
<p className="text-sm text-muted-foreground">Version and instance information.</p>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
@@ -83,10 +83,6 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">Account & Security</h3>
<p className="text-sm text-muted-foreground">Manage your credentials and authentication.</p>
</div>
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<Label>Current Password</Label>
@@ -57,11 +57,6 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">App Store Registry</h3>
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
@@ -38,16 +38,12 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">Developer</h3>
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and data retention.</p>
</div>
{isRemote && (
{isRemote && (
<div className="flex justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5 cursor-help">
<Badge variant="secondary" className="text-xs cursor-help">
<Info className="w-3 h-3 mr-1" />
Always Local
</Badge>
@@ -57,8 +53,8 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
)}
{isLoading ? <SettingsSkeleton /> : (
<>
@@ -125,11 +125,7 @@ export function LabelsSection() {
<PaidGate featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-semibold tracking-tight">Stack Labels</h2>
<p className="text-sm text-muted-foreground">Organize stacks with colored labels for filtering and bulk actions.</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New Label'}
@@ -48,11 +48,6 @@ export function LicenseSection() {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">License</h3>
<p className="text-sm text-muted-foreground">Manage your Sencho license.</p>
</div>
{/* Current Tier Display */}
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
<div className="flex items-center justify-between">
@@ -30,7 +30,6 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from '@/components/AdmiralGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
interface NotificationRoute {
@@ -233,15 +232,7 @@ export function NotificationRoutingSection() {
<AdmiralGate featureName="Notification Routing">
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Notification Routing <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Route stack alerts to specific channels instead of your global notification endpoints.
</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" /> Add Route
</Button>
@@ -5,17 +5,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { RefreshCw, Info } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import type { Agent } from './types';
export function NotificationsSection() {
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord');
const [agents, setAgents] = useState<Record<string, Agent>>({
@@ -127,32 +124,6 @@ export function NotificationsSection() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">Notifications & Alerts</h3>
<p className="text-sm text-muted-foreground">
{isRemote
? <>Configuring notification channels on <span className="font-medium text-foreground">{activeNode!.name}</span>. Alerts from this remote node will dispatch via these channels.</>
: 'Configure external integrations for crash alerts.'
}
</p>
</div>
{isRemote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5 cursor-help">
<Info className="w-3 h-3 mr-1" />
Remote
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-center">
These channels are saved on the remote Sencho instance and used when it dispatches alerts.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
@@ -27,7 +27,6 @@ import {
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { PaidGate } from '@/components/PaidGate';
import { TierBadge } from '@/components/TierBadge';
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
import { useLicense } from '@/context/LicenseContext';
@@ -256,14 +255,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
if (!isPaid) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Security <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Define vulnerability scan policies that gate or warn on deploys.
</p>
</div>
<PaidGate featureName="Scan Policies">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
@@ -276,22 +267,14 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Security <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Policies evaluate every post-deploy scan and alert (or block) when severity exceeds the threshold.
</p>
</div>
{!isReplica && (
{!isReplica && (
<div className="flex justify-end">
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Policy
</Button>
)}
</div>
</div>
)}
{isReplica && (
<div
@@ -8,11 +8,6 @@ export function SupportSection() {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">Help & Support</h3>
<p className="text-sm text-muted-foreground">Get help with Sencho based on your plan.</p>
</div>
{/* Self-serve channels (all tiers) */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Resources</h4>
+216 -106
View File
@@ -1,11 +1,8 @@
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Info } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PatchableSettings } from './types';
interface SystemSectionProps {
@@ -14,118 +11,231 @@ interface SystemSectionProps {
onSave: () => Promise<void>;
isSaving: boolean;
isLoading: boolean;
isRemote: boolean;
activeNodeName?: string;
}
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')}
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',
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>
);
}
interface RowProps {
label: string;
desc: string;
control: React.ReactNode;
last?: boolean;
}
function Row({ label, desc, control, last }: RowProps) {
return (
<div
className={cn(
'flex items-center gap-4 px-4 py-3',
!last && 'border-b border-glass-border',
)}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-stat-value">{label}</div>
<div className="mt-0.5 text-xs text-stat-subtitle">{desc}</div>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<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>
);
}
export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote, activeNodeName }: SystemSectionProps) {
export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading }: SystemSectionProps) {
if (isLoading) return <SettingsSkeleton />;
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">System Limits & Watchdog</h3>
<p className="text-sm text-muted-foreground">Configure alert thresholds and crash detection.</p>
</div>
{isRemote && (
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
<Info className="w-3 h-3 mr-1" />
Configuring: {activeNodeName}
</Badge>
)}
<div className="overflow-hidden rounded-lg border border-glass-border bg-glass">
<Row
label="Host CPU limit"
desc="Alerts fire when 5-min avg exceeds"
control={
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Host RAM limit"
desc="Swap is never acceptable"
control={
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Host disk limit"
desc="Low free space slows image pulls and backups"
control={
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Janitor threshold"
desc="Alert when reclaimable Docker data exceeds this"
control={
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
}
/>
<Row
last
label="Global crash capture"
desc="Watch every managed container for unexpected exits"
control={
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
}
/>
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label className="text-base">Host CPU Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_cpu_limit || '90')]}
onValueChange={(v) => onSettingChange('host_cpu_limit', v[0].toString())}
/>
</div>
<div className="space-y-4 pt-2 border-t border-glass-border">
<div className="flex justify-between items-center">
<Label className="text-base">Host RAM Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_ram_limit || '90')]}
onValueChange={(v) => onSettingChange('host_ram_limit', v[0].toString())}
/>
</div>
<div className="space-y-4 pt-2 border-t border-glass-border">
<div className="flex justify-between items-center">
<Label className="text-base">Host Disk Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_disk_limit || '90')]}
onValueChange={(v) => onSettingChange('host_disk_limit', v[0].toString())}
/>
</div>
<div className="space-y-2 pt-2 border-t border-glass-border">
<Label className="text-base">Docker Janitor Storage Threshold</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
step={0.5}
value={settings.docker_janitor_gb}
onChange={(e) => onSettingChange('docker_janitor_gb', e.target.value)}
className="max-w-[150px]"
/>
<span className="text-sm text-muted-foreground">GB reclaimable</span>
</div>
<p className="text-xs text-muted-foreground">Alert when unused Docker data exceeds this size.</p>
</div>
<div className="flex items-center justify-between pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
<p className="text-xs text-muted-foreground">Watch all containers for unexpected exits</p>
</div>
<Switch
id="global_crash"
checked={settings.global_crash === '1'}
onCheckedChange={(c) => onSettingChange('global_crash', c ? '1' : '0')}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save Limits'
}
</Button>
</div>
</>
)}
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save limits'
}
</Button>
</div>
</div>
);
}
@@ -251,17 +251,13 @@ export function UsersSection() {
<PaidGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">User Management</h3>
<p className="text-sm text-muted-foreground">Create and manage user accounts with role-based access control.</p>
</div>
{!showForm && (
{!showForm && (
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1" strokeWidth={1.5} />Add User
</Button>
)}
</div>
</div>
)}
{/* Add/Edit Form */}
{showForm && (
@@ -10,7 +10,6 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
@@ -137,10 +136,6 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
if (!isPaid) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<PaidGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
@@ -155,11 +150,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" /> Create Webhook
</Button>
+16
View File
@@ -13,3 +13,19 @@ export { LabelsSection } from './LabelsSection';
export { NotificationRoutingSection } from './NotificationRoutingSection';
export { DEFAULT_SETTINGS } from './types';
export type { PatchableSettings, SectionId, Agent } from './types';
export {
SETTINGS_GROUPS,
SETTINGS_ITEMS,
getSettingsItem,
getSettingsGroup,
isItemVisible,
isItemLocked,
} from './registry';
export type {
SettingsGroupId,
SettingsGroupMeta,
SettingsItemMeta,
TierGate,
Scope,
VisibilityContext,
} from './registry';
@@ -0,0 +1,233 @@
import type { SectionId } from './types';
export type SettingsGroupId = 'identity' | 'system' | 'alerts' | 'advanced';
export interface SettingsGroupMeta {
id: SettingsGroupId;
label: string;
kicker?: string;
glyph: string;
}
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' },
];
export type TierGate = 'skipper' | 'admiral' | null;
export type Scope = 'global' | 'node';
export interface SettingsItemMeta {
id: SectionId;
group: SettingsGroupId;
label: string;
description: string;
keywords: string[];
tier: TierGate;
scope: Scope;
adminOnly?: boolean;
hiddenOnRemote?: boolean;
}
export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
{
id: 'account',
group: 'identity',
label: 'Account',
description: 'Password, MFA, and session controls for the signed-in operator.',
keywords: ['password', 'mfa', 'two-factor', 'session', 'profile'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'license',
group: 'identity',
label: 'License',
description: 'Activation key, plan tier, and seat allocation.',
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'users',
group: 'identity',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
tier: 'skipper',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'sso',
group: 'identity',
label: 'SSO',
description: 'Single sign-on via SAML or OIDC identity providers.',
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'api-tokens',
group: 'identity',
label: 'API Tokens',
description: 'Long-lived bearer tokens for CI, scripts, and remote nodes.',
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
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'],
tier: null,
scope: 'node',
},
{
id: 'registries',
group: 'system',
label: 'Registries',
description: 'Private Docker registries and pull credentials.',
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'nodes',
group: 'system',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
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'],
tier: null,
scope: 'global',
},
{
id: 'notification-routing',
group: 'alerts',
label: 'Routing',
description: 'Rules that steer alerts to the right channel based on severity or label.',
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'webhooks',
group: 'alerts',
label: 'Webhooks',
description: 'Outbound HTTP hooks to Slack, Discord, Teams, or custom endpoints.',
keywords: ['slack', 'discord', 'teams', 'webhook', 'outbound'],
tier: 'skipper',
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'labels',
group: 'advanced',
label: 'Labels',
description: 'Shared labels for stacks, containers, and nodes.',
keywords: ['labels', 'tags', 'palette', 'organisation'],
tier: 'skipper',
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'security',
group: 'advanced',
label: 'Security',
description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
tier: 'skipper',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'developer',
group: 'advanced',
label: 'Developer',
description: 'Retention windows, log refresh cadence, and debug modes.',
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
tier: null,
scope: 'node',
},
{
id: 'appstore',
group: 'advanced',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'support',
group: 'advanced',
label: 'Support',
description: 'Diagnostics bundle, docs links, and contact channels.',
keywords: ['help', 'diagnostics', 'bundle', 'docs', 'contact'],
tier: null,
scope: 'global',
},
{
id: 'about',
group: 'advanced',
label: 'About',
description: 'Build metadata, release notes, and licence attributions.',
keywords: ['version', 'build', 'release', 'attributions'],
tier: null,
scope: 'global',
},
];
export function getSettingsItem(id: SectionId): SettingsItemMeta | undefined {
return SETTINGS_ITEMS.find(item => item.id === id);
}
export function getSettingsGroup(id: SettingsGroupId): SettingsGroupMeta | undefined {
return SETTINGS_GROUPS.find(group => group.id === id);
}
export interface VisibilityContext {
isRemote: boolean;
isAdmin: boolean;
isPaid: boolean;
isAdmiral: boolean;
}
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
if (ctx.isRemote && item.hiddenOnRemote) return false;
if (item.adminOnly && !ctx.isAdmin) return false;
return true;
}
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
if (item.tier === 'skipper') return !ctx.isPaid;
if (item.tier === 'admiral') return !ctx.isAdmiral;
return false;
}