mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +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:
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 |
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user