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
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+29 -2
View File
@@ -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.
<Frame>
<img src="/images/settings/settings-overview.png" alt="Settings Hub showing the Account tab and the full section sidebar" />
<img src="/images/settings/settings-hub-grouped.png" alt="Settings Hub with the grouped sidebar: Identity, System, Alerts, Advanced" />
</Frame>
### 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.
<Frame>
<img src="/images/settings/settings-search.png" alt="Settings command palette filtered to webhook" />
</Frame>
### 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.
<Frame>
<img src="/images/settings/settings-node-scope.png" alt="System Limits section showing the node-scope indicator in the breadcrumb" />
</Frame>
---
+1 -1
View File
@@ -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). */
+1 -10
View File
@@ -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() {
<AdmiralGate featureName="API Tokens">
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<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">
API Tokens <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Generate scoped tokens for CI/CD pipelines, scripts, and automation.
</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Create Token
</Button>
+3 -12
View File
@@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-medium flex items-center gap-2">
<Server className="w-5 h-5" />
Nodes
</h2>
<p className="text-sm text-muted-foreground">
Manage connections to local and remote Sencho instances
</p>
</div>
{/* Actions */}
<div className="flex justify-end">
<Dialog
open={createOpen}
onOpenChange={(open) => {
+1 -10
View File
@@ -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() {
<AdmiralGate featureName="Private Registry Management">
<CapabilityGate capability="registries" featureName="Private Registries">
<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">
Private Registries <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Store credentials for private Docker registries. Sencho injects them automatically during deploy and pull operations.
</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Add Registry
</Button>
+1 -12
View File
@@ -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 (
<CapabilityGate capability="sso" featureName="SSO Authentication">
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
<Shield className="w-5 h-5" />
SSO Authentication
</h3>
<p className="text-sm text-muted-foreground mt-1">
Connect your identity provider so team members can sign in with their existing credentials.
SSO works alongside password authentication - it does not replace it.
</p>
</div>
<div className="space-y-3">
{PROVIDERS.map(p => (
<ProviderCard
+265 -176
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
import {
Dialog,
DialogClose,
@@ -8,16 +8,20 @@ import {
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import {
Shield, Activity, Bell, Code, Server, Package, X,
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, ShieldCheck,
} from 'lucide-react';
import { Search, X, Lock } from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
@@ -40,14 +44,19 @@ import {
AboutSection,
LabelsSection,
DEFAULT_SETTINGS,
SETTINGS_GROUPS,
SETTINGS_ITEMS,
getSettingsItem,
getSettingsGroup,
isItemVisible,
isItemLocked,
} from './settings';
import type {
PatchableSettings,
SectionId,
SettingsItemMeta,
VisibilityContext,
} from './settings';
import type { PatchableSettings, SectionId } from './settings';
const GLOBAL_ONLY_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>([
'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<SectionId>(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<HTMLDivElement | null>(null);
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
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<PatchableSettings>({ ...DEFAULT_SETTINGS });
const serverSettingsRef = useRef<PatchableSettings>({ ...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<Record<SectionId, boolean>> = {
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;
}) => (
<Button
variant={activeSection === section ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium relative"
onClick={() => switchSection(section)}
>
{icon}
{label}
{locked && <Lock className="w-3 h-3 ml-auto text-muted-foreground/50" />}
{showDot && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-amber-400" />
)}
</Button>
);
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 <LicenseSection />;
case 'users':
return <UsersSection />;
case 'sso':
return <SSOSection />;
case 'api-tokens':
return <ApiTokensSection />;
case 'registries':
return <RegistriesSection />;
case 'labels':
return <LabelsSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'system':
return (
<SystemSection
@@ -297,18 +312,12 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
onSave={saveSystemSettings}
isSaving={isSavingSystem}
isLoading={isSettingsLoading}
isRemote={isRemote}
activeNodeName={activeNode?.name}
/>
);
case 'notifications':
return <NotificationsSection />;
case 'notification-routing':
return <NotificationRoutingSection />;
case 'webhooks':
return <WebhooksSection isPaid={isPaid} />;
case 'security':
return <SecuritySection isPaid={isPaid} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'developer':
return (
<DeveloperSection
@@ -320,8 +329,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
isRemote={isRemote}
/>
);
case 'nodes':
return <NodeManager />;
case 'nodes': return <NodeManager />;
case 'appstore':
return (
<AppStoreSection
@@ -331,118 +339,199 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
onSaved={handleRegistrySaved}
/>
);
case 'support':
return <SupportSection />;
case 'about':
return <AboutSection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return null;
}
};
const isAdmiral = isPaid && license?.variant === 'admiral';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent showClose={false} className="sm:max-w-[900px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showClose={false}
onKeyDownCapture={handleDialogKeyDown}
className="sm:max-w-[960px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0"
>
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
{/* Sidebar */}
<div className="w-[200px] bg-glass border-r border-glass-border flex flex-col p-4 shrink-0 min-h-0">
<div className="font-medium text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
{isRemote ? (
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
) : (
<div className="mb-5" />
)}
<ScrollArea className="flex-1 -mr-2 pr-2">
<nav className="space-y-1.5 flex flex-col">
{/* Account / License */}
{!isRemote && (
<NavButton section="account" icon={<Shield className="w-4 h-4 mr-2" />} label="Account" />
)}
{!isRemote && (
<NavButton section="license" icon={<Crown className="w-4 h-4 mr-2" />} label="License" />
)}
{!isRemote && <Separator className="my-1.5" />}
{/* Users / SSO / API Tokens / Registries */}
{!isRemote && isAdmin && (
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" />
)}
{!isRemote && isAdmin && (
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPaid} />
)}
{!isRemote && isAdmin && <Separator className="my-1.5" />}
{/* System / Notifications / Webhooks / Developer */}
<NavButton
section="system"
icon={<Activity className="w-4 h-4 mr-2" />}
label="System Limits"
showDot={hasSystemChanges}
/>
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
{!isRemote && isAdmin && (
<NavButton section="notification-routing" icon={<Route className="w-4 h-4 mr-2" />} label="Routing" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="security" icon={<ShieldCheck className="w-4 h-4 mr-2" />} label="Security" locked={!isPaid} />
)}
<NavButton
section="developer"
icon={<Code className="w-4 h-4 mr-2" />}
label="Developer"
showDot={hasDeveloperChanges}
/>
<Separator className="my-1.5" />
{/* Nodes / App Store */}
{!isRemote && (
<NavButton section="nodes" icon={<Server className="w-4 h-4 mr-2" />} label="Nodes" />
)}
{!isRemote && (
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
)}
<Separator className="my-1.5" />
{/* Support / About */}
<NavButton section="support" icon={<LifeBuoy className="w-4 h-4 mr-2" />} label="Support" />
<NavButton section="about" icon={<Info className="w-4 h-4 mr-2" />} label="About" />
</nav>
</ScrollArea>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-end shrink-0 px-3 pt-3">
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" strokeWidth={1.5} />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<ScrollArea viewportRef={contentViewportRef} className="flex-1">
<div className="px-6 pb-6 flex flex-col gap-6">
{renderSection()}
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
<div className="px-4 pt-4 pb-3">
<div className="font-display italic text-xl leading-none text-stat-value">Settings</div>
<div className="mt-1 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{isRemote && activeNode ? `node · ${activeNode.name}` : 'control plane'}
</div>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<button
type="button"
onClick={() => setCommandOpen(true)}
className="mx-3 mb-3 flex items-center gap-2 rounded-md border border-card-border border-t-card-border-top bg-card px-2.5 py-1.5 shadow-card-bevel transition-colors hover:border-t-card-border-hover"
>
<Search className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />
<span className="flex-1 text-left font-mono text-[11px] text-stat-subtitle">Filter settings</span>
<kbd className="font-mono text-[10px] text-stat-subtitle/70 tabular-nums">{`\u2318K`}</kbd>
</button>
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-4 pb-4">
{visibleGroups.map(group => (
<div key={group.id} className="flex flex-col gap-0.5">
<div className="flex items-baseline gap-1.5 px-2 pb-1 font-mono text-[9px] uppercase tracking-[0.22em] text-stat-subtitle">
<span>{group.label}</span>
{group.kicker ? (
<span className="text-stat-subtitle/60">· {group.kicker}</span>
) : null}
</div>
{group.items.map(item => {
const locked = isItemLocked(item, visibility);
const isActive = activeSection === item.id;
const showDot = sectionDirtyFlags[item.id];
return (
<button
type="button"
key={item.id}
onClick={() => switchSection(item.id)}
className={cn(
'relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-left transition-colors',
isActive
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
)}
>
{isActive ? (
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
) : null}
<span
aria-hidden="true"
className={cn(
'font-mono text-[11px] leading-none w-3 text-center',
isActive ? 'text-brand' : 'text-stat-subtitle/70',
)}
>
{group.glyph}
</span>
<span className="flex-1 truncate text-sm font-medium">{item.label}</span>
{item.tier ? (
<TierChip tier={item.tier} locked={locked} />
) : null}
{showDot ? (
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
) : null}
</button>
);
})}
</div>
))}
</nav>
</ScrollArea>
</aside>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Settings</span>
<span className="text-stat-subtitle/50">{`\u203A`}</span>
<span>{activeGroup?.label ?? ''}</span>
<span className="text-stat-subtitle/50">{`\u203A`}</span>
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
{activeItem?.scope === 'node' ? (
<span className="ml-2 flex items-center gap-1 text-brand">
<span className="text-stat-subtitle/50">·</span>
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
<span className="text-stat-subtitle/70">(node-scoped)</span>
</span>
) : null}
</div>
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
{activeItem?.label ?? 'Settings'}
</h2>
{activeItem?.description ? (
<div className="mt-1 text-sm text-stat-subtitle/90 truncate">
{activeItem.description}
</div>
) : null}
</div>
<DialogClose className="mt-1 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" strokeWidth={1.5} />
<span className="sr-only">Close</span>
</DialogClose>
</header>
<ScrollArea block viewportRef={contentViewportRef} className="flex-1 min-w-0">
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
{renderSection()}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Jump to a setting..." />
<CommandList>
<CommandEmpty>No matching settings.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup key={group.id} heading={group.label}>
{group.items.map(item => (
<CommandSearchItem
key={item.id}
item={item}
glyph={group.glyph}
visibility={visibility}
onSelect={() => {
setCommandOpen(false);
switchSection(item.id);
}}
/>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}
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 (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier ? <TierChip tier={item.tier} locked={locked} /> : null}
</CommandItem>
);
}
function TierChip({ tier, locked }: { tier: NonNullable<SettingsItemMeta['tier']>; locked: boolean }) {
const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER';
if (locked) {
return (
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
{label}
</span>
);
}
return (
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-brand/70">
{label}
</span>
);
}
@@ -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;
}
+13 -3
View File
@@ -7,8 +7,14 @@ const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>;
// 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) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
@@ -16,11 +22,15 @@ const ScrollArea = React.forwardRef<
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
className="h-full w-full rounded-[inherit]"
className={cn(
"h-full w-full rounded-[inherit]",
block && "[&>div]:!block [&>div]:!min-w-0"
)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation="vertical" />
{block && <ScrollBar orientation="horizontal" />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))