diff --git a/docs/images/settings/settings-hub-grouped.png b/docs/images/settings/settings-hub-grouped.png new file mode 100644 index 00000000..236c3290 Binary files /dev/null and b/docs/images/settings/settings-hub-grouped.png differ diff --git a/docs/images/settings/settings-node-scope.png b/docs/images/settings/settings-node-scope.png new file mode 100644 index 00000000..70f3dc70 Binary files /dev/null and b/docs/images/settings/settings-node-scope.png differ diff --git a/docs/images/settings/settings-search.png b/docs/images/settings/settings-search.png new file mode 100644 index 00000000..763e8116 Binary files /dev/null and b/docs/images/settings/settings-search.png differ diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 43618eb1..828d7c7f 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -3,10 +3,37 @@ title: Settings Reference description: Complete reference for every option in the Sencho Settings Hub. --- -Open the Settings Hub by clicking the **Profile** icon in the top bar and selecting **Settings**. The left sidebar lists all available sections. Some sections show a lock icon when they require a higher license tier. +Open the Settings Hub by clicking the **Profile** icon in the top bar and selecting **Settings**. The sidebar groups every section into four themes so related settings live together. - Settings Hub showing the Account tab and the full section sidebar + Settings Hub with the grouped sidebar: Identity, System, Alerts, Advanced + + +### Sidebar groups + +| Group | What it covers | +|-------|----------------| +| **Identity** | Account, License, Users, SSO, API Tokens | +| **System** (node-scoped) | System Limits, Registries, Nodes | +| **Alerts** | Notifications, Routing, Webhooks | +| **Advanced** | Labels, Security, Developer, App Store, Support, About | + +Sections that require a higher license tier show a tier chip (`SKIPPER` or `ADMIRAL`) next to their name. Admin-only sections stay hidden for non-admins. + +### Quick search + +Click **Filter settings** at the top of the sidebar, or press `Ctrl+K` / `⌘K` while the hub is open, to open the command palette. Type any section name, keyword, or synonym (for example, `saml` finds SSO and `trivy` finds Security) and press Enter to jump to it. + + + Settings command palette filtered to webhook + + +### Scope breadcrumb + +The content pane shows a breadcrumb such as `SETTINGS › SYSTEM › SYSTEM LIMITS`. For node-scoped sections the breadcrumb appends the active node name (for example, `LOCAL (NODE-SCOPED)`) so you always know which node you are editing. + + + System Limits section showing the node-scope indicator in the breadcrumb --- diff --git a/e2e/mfa.spec.ts b/e2e/mfa.spec.ts index ec20e13a..3e3488fd 100644 --- a/e2e/mfa.spec.ts +++ b/e2e/mfa.spec.ts @@ -26,7 +26,7 @@ async function logout(page: Page) { async function openAccountSettings(page: Page) { await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: /settings/i }).click(); - await expect(page.getByRole('heading', { name: /Account & Security/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /^Account$/i })).toBeVisible(); } /** Fill a login form (no MFA branch). */ diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 0a3a8ac2..9745d590 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -10,7 +10,6 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; -import { TierBadge } from './TierBadge'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; interface ApiTokenListItem { @@ -132,15 +131,7 @@ export function ApiTokensSection() {
-
-
-

- API Tokens -

-

- Generate scoped tokens for CI/CD pipelines, scripts, and automation. -

-
+
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 0091edcb..9bafd9c0 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -14,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Combobox } from './ui/combobox'; -import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; +import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; interface NodeSchedulingSummary { active_tasks: number; @@ -430,17 +430,8 @@ export function NodeManager() { return (
- {/* Header */} -
-
-

- - Nodes -

-

- Manage connections to local and remote Sencho instances -

-
+ {/* Actions */} +
{ diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index d6dd5e7f..558feff2 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -10,7 +10,6 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; -import { TierBadge } from './TierBadge'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react'; type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; @@ -275,15 +274,7 @@ export function RegistriesSection() {
-
-
-

- Private Registries -

-

- Store credentials for private Docker registries. Sencho injects them automatically during deploy and pull operations. -

-
+
diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 62a16397..c43f1896 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { CapabilityGate } from './CapabilityGate'; -import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { Loader2, CheckCircle, XCircle } from 'lucide-react'; const ROLE_OPTIONS = [ { value: 'viewer', label: 'Viewer' }, @@ -397,17 +397,6 @@ export function SSOSection() { return (
-
-

- - SSO Authentication -

-

- Connect your identity provider so team members can sign in with their existing credentials. - SSO works alongside password authentication - it does not replace it. -

-
-
{PROVIDERS.map(p => ( = new Set([ - 'account', 'license', 'users', 'sso', 'api-tokens', 'registries', - 'labels', 'notifications', 'notification-routing', 'webhooks', 'security', - 'nodes', 'appstore', -]); interface SettingsModalProps { isOpen: boolean; @@ -60,17 +69,38 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal const { isAdmin } = useAuth(); const { license, isPaid } = useLicense(); const isRemote = activeNode?.type === 'remote'; + const isAdmiral = isPaid && license?.variant === 'admiral'; const [activeSection, setActiveSection] = useState(initialSection || 'account'); + const [commandOpen, setCommandOpen] = useState(false); + + const visibility: VisibilityContext = useMemo( + () => ({ isRemote, isAdmin, isPaid, isAdmiral }), + [isRemote, isAdmin, isPaid, isAdmiral], + ); + + const visibleItems = useMemo( + () => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)), + [visibility], + ); + + const visibleGroups = useMemo(() => { + return SETTINGS_GROUPS + .map(group => ({ + ...group, + items: visibleItems.filter(item => item.group === group.id), + })) + .filter(group => group.items.length > 0); + }, [visibleItems]); const contentViewportRef = useRef(null); const scrollPositionsRef = useRef>>({}); - const switchSection = (next: SectionId) => { + const switchSection = useCallback((next: SectionId) => { if (contentViewportRef.current) { scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop; } setActiveSection(next); - }; + }, [activeSection]); useLayoutEffect(() => { if (contentViewportRef.current) { @@ -82,25 +112,31 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal if (isOpen && initialSection) setActiveSection(initialSection); }, [isOpen, initialSection]); - // Remote nodes don't expose global-only sections, so bounce to a node-scoped one. useEffect(() => { - if (isRemote && GLOBAL_ONLY_SECTIONS.has(activeSection)) { - switchSection('system'); + const current = getSettingsItem(activeSection); + if (!current) return; + if (!isItemVisible(current, visibility) && visibleItems.length > 0) { + setActiveSection(visibleItems[0].id); } - }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps + }, [visibility, visibleItems, activeSection]); + + const handleDialogKeyDown = useCallback((event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + event.stopPropagation(); + setCommandOpen(open => !open); + } + }, []); - // Auth State const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); const [isSavingPassword, setIsSavingPassword] = useState(false); - // Settings state const [settings, setSettings] = useState({ ...DEFAULT_SETTINGS }); const serverSettingsRef = useRef({ ...DEFAULT_SETTINGS }); const [isSettingsLoading, setIsSettingsLoading] = useState(false); const [isSavingSystem, setIsSavingSystem] = useState(false); const [isSavingDeveloper, setIsSavingDeveloper] = useState(false); - // Unsaved changes indicators const hasSystemChanges = settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit || settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit || @@ -115,6 +151,11 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal settings.log_retention_days !== serverSettingsRef.current.log_retention_days || settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days; + const sectionDirtyFlags: Partial> = { + system: hasSystemChanges, + developer: hasDeveloperChanges, + }; + useEffect(() => { if (isOpen) fetchSettings(); }, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps @@ -243,29 +284,9 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value }; }; - // --- Nav items --- - const NavButton = ({ section, icon, label, showDot, locked }: { - section: SectionId; - icon: React.ReactNode; - label: string; - showDot?: boolean; - locked?: boolean; - }) => ( - - ); + const activeItem = getSettingsItem(activeSection); + const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined; - // --- Section rendering --- const renderSection = () => { switch (activeSection) { case 'account': @@ -277,18 +298,12 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal isSaving={isSavingPassword} /> ); - case 'license': - return ; - case 'users': - return ; - case 'sso': - return ; - case 'api-tokens': - return ; - case 'registries': - return ; - case 'labels': - return ; + case 'license': return ; + case 'users': return ; + case 'sso': return ; + case 'api-tokens': return ; + case 'registries': return ; + case 'labels': return ; case 'system': return ( ); - case 'notifications': - return ; - case 'notification-routing': - return ; - case 'webhooks': - return ; - case 'security': - return ; + case 'notifications': return ; + case 'notification-routing': return ; + case 'webhooks': return ; + case 'security': return ; case 'developer': return ( ); - case 'nodes': - return ; + case 'nodes': return ; case 'appstore': return ( ); - case 'support': - return ; - case 'about': - return ; + case 'support': return ; + case 'about': return ; + default: return null; } }; - const isAdmiral = isPaid && license?.variant === 'admiral'; - return ( - !open && onClose()}> - - Settings Hub - Configure Sencho settings + <> + !open && onClose()}> + + Settings Hub + Configure Sencho settings - {/* Sidebar */} -
-
Settings Hub
- {isRemote ? ( -
{activeNode!.name}
- ) : ( -
- )} - - - -
- - {/* Main Content Area */} -
-
- - - Close - -
- -
- {renderSection()} +
- -
+ + + + + + + + +
+
+
+
+ Settings + {`\u203A`} + {activeGroup?.label ?? ''} + {`\u203A`} + {activeItem?.label ?? ''} + {activeItem?.scope === 'node' ? ( + + · + {activeNode?.name ?? 'local'} + (node-scoped) + + ) : null} +
+

+ {activeItem?.label ?? 'Settings'} +

+ {activeItem?.description ? ( +
+ {activeItem.description} +
+ ) : null} +
+ + + Close + +
+ +
+ {renderSection()} +
+
+
+
+
+ + + + + No matching settings. + {visibleGroups.map(group => ( + + {group.items.map(item => ( + { + setCommandOpen(false); + switchSection(item.id); + }} + /> + ))} + + ))} + + + + ); +} + +function CommandSearchItem({ + item, + glyph, + visibility, + onSelect, +}: { + item: SettingsItemMeta; + glyph: string; + visibility: VisibilityContext; + onSelect: () => void; +}) { + const locked = isItemLocked(item, visibility); + const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase(); + return ( + + {glyph} +
+ {item.label} + {item.description} +
+ {item.tier ? : null} +
+ ); +} + +function TierChip({ tier, locked }: { tier: NonNullable; locked: boolean }) { + const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'; + if (locked) { + return ( + + + {label} + + ); + } + return ( + + {label} + ); } diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx index ded9b187..77b419e4 100644 --- a/frontend/src/components/settings/AboutSection.tsx +++ b/frontend/src/components/settings/AboutSection.tsx @@ -7,11 +7,6 @@ export function AboutSection() { return (
-
-

About Sencho

-

Version and instance information.

-
-
Version diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx index 476a01ed..e9fffa54 100644 --- a/frontend/src/components/settings/AccountSection.tsx +++ b/frontend/src/components/settings/AccountSection.tsx @@ -83,10 +83,6 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i return (
-
-

Account & Security

-

Manage your credentials and authentication.

-
diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx index bf078823..867b9f17 100644 --- a/frontend/src/components/settings/AppStoreSection.tsx +++ b/frontend/src/components/settings/AppStoreSection.tsx @@ -57,11 +57,6 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved return (
-
-

App Store Registry

-

Configure the template source used by the App Store.

-
- {isLoading ? : ( <>
diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index f55aef13..31106667 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -38,16 +38,12 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, return (
-
-
-

Developer

-

Power user settings for real-time observability and data retention.

-
- {isRemote && ( + {isRemote && ( +
- + Always Local @@ -57,8 +53,8 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, - )} -
+
+ )} {isLoading ? : ( <> diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 164cb965..6d1adfd2 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -125,11 +125,7 @@ export function LabelsSection() {
-
-
-

Stack Labels

-

Organize stacks with colored labels for filtering and bulk actions.

-
+
diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index febff745..d7fc23e0 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -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>({ @@ -127,32 +124,6 @@ export function NotificationsSection() { return (
-
-
-

Notifications & Alerts

-

- {isRemote - ? <>Configuring notification channels on {activeNode!.name}. Alerts from this remote node will dispatch via these channels. - : 'Configure external integrations for crash alerts.' - } -

-
- {isRemote && ( - - - - - - Remote - - - - These channels are saved on the remote Sencho instance and used when it dispatches alerts. - - - - )} -
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index 6505c6ce..31213d15 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -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 (
-
-

- Security -

-

- Define vulnerability scan policies that gate or warn on deploys. -

-
@@ -276,22 +267,14 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { return (
-
-
-

- Security -

-

- Policies evaluate every post-deploy scan and alert (or block) when severity exceeds the threshold. -

-
- {!isReplica && ( + {!isReplica && ( +
- )} -
+
+ )} {isReplica && (
-
-

Help & Support

-

Get help with Sencho based on your plan.

-
- {/* Self-serve channels (all tiers) */}

Resources

diff --git a/frontend/src/components/settings/SystemSection.tsx b/frontend/src/components/settings/SystemSection.tsx index 044c9035..fff33e34 100644 --- a/frontend/src/components/settings/SystemSection.tsx +++ b/frontend/src/components/settings/SystemSection.tsx @@ -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; 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(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 ( + + 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" + /> + {suffix} + + ); + } + + return ( + + ); +} + +interface TogglePillProps { + checked: boolean; + onChange: (next: boolean) => void; +} + +function TogglePill({ checked, onChange }: TogglePillProps) { + return ( + + ); +} + +interface RowProps { + label: string; + desc: string; + control: React.ReactNode; + last?: boolean; +} + +function Row({ label, desc, control, last }: RowProps) { + return ( +
+
+
{label}
+
{desc}
+
+
{control}
+
+ ); } function SettingsSkeleton() { return ( -
- - -
- - - -
+
+ + + +
); } -export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote, activeNodeName }: SystemSectionProps) { +export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading }: SystemSectionProps) { + if (isLoading) return ; + return (
-
-
-

System Limits & Watchdog

-

Configure alert thresholds and crash detection.

-
- {isRemote && ( - - - Configuring: {activeNodeName} - - )} +
+ onSettingChange('host_cpu_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + } + /> + onSettingChange('host_ram_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + } + /> + onSettingChange('host_disk_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + } + /> + onSettingChange('docker_janitor_gb', v)} + suffix="GiB" + min={0} + step={0.5} + warnOver={10} + /> + } + /> + onSettingChange('global_crash', next ? '1' : '0')} + /> + } + />
- {isLoading ? : ( - <> -
-
-
- - {settings.host_cpu_limit}% -
- onSettingChange('host_cpu_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_ram_limit}% -
- onSettingChange('host_ram_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_disk_limit}% -
- onSettingChange('host_disk_limit', v[0].toString())} - /> -
- -
- -
- onSettingChange('docker_janitor_gb', e.target.value)} - className="max-w-[150px]" - /> - GB reclaimable -
-

Alert when unused Docker data exceeds this size.

-
- -
-
- -

Watch all containers for unexpected exits

-
- onSettingChange('global_crash', c ? '1' : '0')} - /> -
-
- -
- -
- - )} +
+ +
); } diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 15642494..f03e79e3 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -251,17 +251,13 @@ export function UsersSection() {
-
-
-

User Management

-

Create and manage user accounts with role-based access control.

-
- {!showForm && ( + {!showForm && ( +
- )} -
+
+ )} {/* Add/Edit Form */} {showForm && ( diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index ff7ada66..7646d995 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -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 (
-
-

Webhooks

-

Trigger stack actions from CI/CD pipelines via HTTP.

-
@@ -155,11 +150,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { return (
-
-
-

Webhooks

-

Trigger stack actions from CI/CD pipelines via HTTP.

-
+
diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 95c9210a..6363087d 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -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'; diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts new file mode 100644 index 00000000..870a424b --- /dev/null +++ b/frontend/src/components/settings/registry.ts @@ -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; +} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx index 15261915..92ea5d70 100644 --- a/frontend/src/components/ui/scroll-area.tsx +++ b/frontend/src/components/ui/scroll-area.tsx @@ -7,8 +7,14 @@ const ScrollArea = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { viewportRef?: React.Ref; + // Opt in when the viewport wraps content that manages its own horizontal + // overflow (e.g., a child `overflow-x-auto` table). Overrides Radix's + // default `display: table` wrapper so the child can be constrained to the + // viewport width and trigger its own scroll. Also renders a horizontal + // ScrollBar for viewports that overflow directly. + block?: boolean; } ->(({ className, children, viewportRef, ...props }, ref) => ( +>(({ className, children, viewportRef, block, ...props }, ref) => ( div]:!block [&>div]:!min-w-0" + )} > {children} - + + {block && } ))