mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user