feat(ui): glassmorphism redesign with settings decomposition (#274)

* feat(ui): add glassmorphism design tokens and utility classes

Introduce glass design system foundation: translucent oklch color
variables for both light and dark themes, glass/glass-border/glass-highlight
tokens, semantic status colors (success/warning/info), .glass and
.glass-strong utility classes with backdrop-filter, reduced shadow
values, and standardized spring animation presets in lib/motion.ts.

* feat(ui): apply glass treatment to core components

Update card, dialog, input, button, popover, sheet, tooltip,
dropdown-menu, context-menu, select, alert-dialog, and tabs
components with glassmorphism styling: translucent backgrounds via
new CSS variables, backdrop-blur layers, glass-border luminous
edges, and glass-highlight hover states.

* refactor(settings): decompose Settings Modal into section components

Extract 10 inline sections from the 1,987-line SettingsModal into
dedicated files under components/settings/. Introduce section
registry pattern replacing 14 conditional blocks. Add shared types,
sidebar navigation grouping with separators, glass treatment on
sidebar and nav buttons, and responsive modal height. SettingsModal
shell shrinks to ~380 lines.

* refactor(ui): unify all tabs to animate-ui TabsHighlight with glass styling

Migrate 4 tab instances (EditorLayout, FleetView, ResourcesView,
NotificationsSection) from inconsistent patterns (manual layoutId,
underline border-b-2, default fade) to the shared TabsHighlight
primitive with glass-highlight indicator and springs.snappy
transition. Standardize EditorLayout nav highlight spring config,
apply glass-highlight to sidebar stack list hover/active states,
and update mobile nav styling.

* refactor(ui): migrate hardcoded colors to semantic CSS variables

Replace hardcoded Tailwind color classes across ~19 component files
with semantic CSS variable classes: emerald/green to success,
orange/amber to warning, blue to info. Preserves brand/decorative
colors (Crown amber, Admiral blue). Enables consistent theming of
status indicators across the entire application.

* refactor(ui): Linear dark precision aesthetic — solid surfaces, depth cues, text hierarchy

Replace glassmorphism with Linear.app-inspired design: solid surface tokens
(card #111111, sidebar #0d0d0d, root #0a0a0a), backdrop-blur restricted to
floating overlays only (blur(10px) saturate(1.15)), desaturated teal accent,
font-weight 500 everywhere, monochrome chart palette, and three depth cues:
root ambient glow, luminous card top-edge, steep text brightness ramp.

* refactor(ui): precision polish — fix muddy dark, snowblind light, add design anchors

- Replace 34 hardcoded rgba values with theme-aware stat-* CSS tokens
- Fix light theme: solid white cards, off-white background, readable text
- Add card-border tokens with sharper directional lighting (top edge 2x)
- Add chart-grid/chart-tick tokens for theme-aware axis rendering
- Upgrade body glow: teal-tinted (dark), warm amber (light)
- Terminal-inspired sidebar: monospaced UP/DN status codes, Geist Mono
- Add tabular-nums to stat values to prevent layout jitter
- Light mode cards get shadow-sm for depth against off-white background

* refactor(ui): Linear materiality pass — ghosted nav, translucent sidebar, font unity

- De-escalate Delete button from solid destructive to ghost with hover fill
- Make sidebar translucent (80% opacity + backdrop-blur) so body glow bleeds through
- Bump dark nav accent to 0.07 for ghosted backlit selection
- Unify all terminal/editor fonts to Geist Mono (was JetBrains/Consolas mix)
- Add Monaco editor fontFamily for YAML/env editing consistency
- Add threshold-based color to Host RAM and Host Disk stat values (warn/crit)

* refactor(ui): material simulation — inherent depth, layer separation, recessed terminal

- Bump dark background 0.065→0.08, card surfaces 0.10→0.12 for 4% layer separation
- Add card-bevel token (inset top shimmer) for permanent structural depth
- Add button-inner-glow token for physical key feel on outline buttons
- Recess terminal with inset shadow and dimmed label
- Reduce action icon strokeWidth to 1.5 for refined industrial feel
- Add teal LED backlight bar on active nav item via blur pseudo-element

* fix(ui): parse usagePercent string to number for getValueColor

usagePercent is typed as string in SystemStats but getValueColor
expects number, causing TS2345 in CI builds.
This commit is contained in:
Anso
2026-03-30 12:48:42 -04:00
committed by GitHub
parent cd4aa746ab
commit 7637091e84
46 changed files with 2483 additions and 2053 deletions
+3 -3
View File
@@ -127,7 +127,7 @@ export function ApiTokensSection() {
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
API Tokens <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
@@ -185,8 +185,8 @@ export function ApiTokensSection() {
{/* Token reveal (shown once after creation) */}
{newToken && (
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<div className="bg-success-muted border border-success/30 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-success">
<CheckCircle className="w-4 h-4" /> Token created - copy it now
</div>
<p className="text-xs text-muted-foreground">This token will not be shown again. Store it securely.</p>
+1 -1
View File
@@ -83,7 +83,7 @@ export function AuditLogView() {
};
const statusColor = (code: number): string => {
if (code >= 200 && code < 300) return 'text-green-500';
if (code >= 200 && code < 300) return 'text-success';
if (code >= 400 && code < 500) return 'text-yellow-500';
if (code >= 500) return 'text-red-500';
return 'text-muted-foreground';
+2 -2
View File
@@ -93,7 +93,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
cursorAccent: '#000000',
selectionBackground: 'rgba(255, 255, 255, 0.3)',
},
fontFamily: 'Consolas, "Courier New", monospace',
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
cursorBlink: true,
});
@@ -219,7 +219,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
<TerminalIcon className="w-5 h-5" />
Bash: {containerName}
{isConnected && (
<span className="ml-2 text-xs bg-green-500/20 text-green-500 px-2 py-0.5 rounded-full">
<span className="ml-2 text-xs bg-success/20 text-success px-2 py-0.5 rounded-full">
Connected
</span>
)}
+55 -54
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { motion } from 'motion/react';
type Theme = 'light' | 'dark' | 'auto';
import Editor from '@monaco-editor/react';
@@ -13,7 +12,8 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
import { springs } from '@/lib/motion';
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
@@ -1084,12 +1084,12 @@ export default function EditorLayout() {
return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
{/* Left Sidebar (Stacks) */}
<div className="w-64 border-r border-border bg-card flex flex-col">
<div className="w-64 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
{/* Branding Header */}
<div className="h-14 flex items-center justify-center px-4 border-b border-border">
<div className="flex items-center gap-2">
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-10 h-10" />
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
<h1 className="text-2xl font-medium tracking-tight">Sencho</h1>
</div>
</div>
@@ -1113,7 +1113,7 @@ export default function EditorLayout() {
{nodes.map(node => (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
{node.name}
@@ -1129,7 +1129,7 @@ export default function EditorLayout() {
{can('stack:create') && <div className="p-4">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full rounded-lg">
<Button variant="outline" className="w-full rounded-lg">
<Plus className="w-4 h-4 mr-2" />
Create Stack
</Button>
@@ -1164,7 +1164,7 @@ export default function EditorLayout() {
className="h-9"
/>
</div>
<h3 className="text-sm font-semibold text-muted-foreground px-4 py-2 mt-2 flex-none">STACKS</h3>
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-icon px-4 py-2 mt-2 flex-none">STACKS</h3>
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={isLoading ? "false" : "true"}>
<CommandList className="max-h-none overflow-visible">
@@ -1182,19 +1182,21 @@ export default function EditorLayout() {
<CommandItem
value={file}
onSelect={() => loadFile(file)}
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-glass-highlight group ${selectedFile === file ? '!bg-glass-highlight !text-foreground border border-glass-border' : ''}`}
>
<div className="flex items-center gap-2 w-full">
<div
className={`w-2 h-2 rounded-full shrink-0 ${stackStatuses[file] === 'running' ? 'bg-green-500' :
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
<span
className={`font-mono text-[10px] shrink-0 w-[18px] ${stackStatuses[file] === 'running' ? 'text-success' :
stackStatuses[file] === 'exited' ? 'text-destructive' : 'text-stat-icon'
}`}
/>
<span className="flex-1 truncate">{getDisplayName(file)}</span>
>
{stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
{stackUpdates[file] && (
<span
className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0"
className="w-2 h-2 rounded-full bg-info animate-pulse shrink-0"
title="Update available"
/>
)}
@@ -1312,13 +1314,13 @@ export default function EditorLayout() {
{/* LEFT ZONE: Node Context Pill */}
<div className="flex-shrink-0">
{activeNode?.type === 'remote' ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0" />
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-info-muted border border-info/20 text-info text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-info animate-pulse shrink-0" />
{activeNode.name}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border text-muted-foreground text-sm">
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
<span className="w-2 h-2 rounded-full bg-success shrink-0" />
{activeNode?.name ?? 'Local'}
</div>
)}
@@ -1327,24 +1329,26 @@ export default function EditorLayout() {
{/* CENTER ZONE: Navigation Group (hidden on mobile) */}
<div className="flex-1 hidden md:flex justify-center">
<Highlight
className="inset-0 rounded-md bg-background shadow-sm"
className="inset-0 rounded-md bg-accent"
value={navTabValue}
controlledItems
mode="children"
click={false}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
transition={springs.snappy}
>
<div className="inline-flex items-center rounded-lg bg-muted/50 p-1 gap-0.5">
<div className="inline-flex items-center rounded-lg p-1 gap-0.5">
{navItems.map(({ value, label, icon: Icon }) => (
<HighlightItem key={value} value={value}>
<button
onClick={() => handleNavigate(value)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
activeView === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
'relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
activeView === value
? 'text-foreground after:absolute after:bottom-0 after:left-1/4 after:right-1/4 after:h-[2px] after:rounded-full after:bg-brand after:blur-[2px]'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className="w-3.5 h-3.5 shrink-0" />
<Icon className="w-4 h-4 shrink-0" />
<span className="hidden xl:inline">{label}</span>
</button>
</HighlightItem>
@@ -1373,7 +1377,7 @@ export default function EditorLayout() {
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<h4 className="font-medium">Notifications</h4>
<div className="flex gap-2">
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
@@ -1444,7 +1448,7 @@ export default function EditorLayout() {
</SheetTrigger>
<SheetContent side="right" className="w-64 p-0">
<div className="p-4 border-b">
<p className="text-sm font-semibold">Navigation</p>
<p className="text-sm font-medium">Navigation</p>
</div>
<nav className="flex flex-col p-2 gap-1">
{navItems.map(({ value, label, icon: Icon }) => (
@@ -1454,8 +1458,8 @@ export default function EditorLayout() {
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
activeView === value
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
? 'bg-glass-highlight font-medium text-foreground'
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground'
)}
>
<Icon className="w-4 h-4" />
@@ -1486,29 +1490,29 @@ export default function EditorLayout() {
<CardHeader className="p-4 pb-2">
<div className="flex flex-col gap-3">
{/* Stack Name */}
<CardTitle className="text-2xl font-bold">{stackName}</CardTitle>
<CardTitle className="text-2xl font-medium">{stackName}</CardTitle>
{/* Action Bar */}
{can('stack:deploy', 'stack', stackName) && (
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
<Square className="w-4 h-4 mr-2" />
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" />
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
</>
) : (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" />
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' || loadingAction === 'start' ? 'Starting...' : 'Start'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
<CloudDownload className="w-4 h-4 mr-2" />
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
{isPro && backupInfo.exists && (
@@ -1516,7 +1520,7 @@ export default function EditorLayout() {
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={rollbackStack} disabled={loadingAction !== null}>
<Undo2 className="w-4 h-4 mr-2" />
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}
</Button>
</TooltipTrigger>
@@ -1531,15 +1535,15 @@ export default function EditorLayout() {
<Button
type="button"
size="sm"
variant="destructive"
className="rounded-lg"
variant="ghost"
className="rounded-lg text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="w-4 h-4 mr-2" />
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</Button>
</div>
@@ -1549,7 +1553,7 @@ export default function EditorLayout() {
<CardContent className="p-4 pt-2">
{/* Containers List */}
<div className="mt-4">
<h4 className="text-sm font-semibold text-muted-foreground mb-3">CONTAINERS</h4>
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
@@ -1584,7 +1588,7 @@ export default function EditorLayout() {
</HoverCardTrigger>
<HoverCardContent className="flex w-50 flex-col gap-0.5">
<div className="space-y-1">
<h4 className="text-sm font-semibold">Container Status</h4>
<h4 className="text-sm font-medium">Container Status</h4>
<p className="text-sm text-muted-foreground">
{container?.Status || 'No status details available'}
</p>
@@ -1664,8 +1668,8 @@ export default function EditorLayout() {
</Card>
{/* Terminal Section */}
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px]">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Terminal</h3>
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
<h3 className="text-sm font-medium text-stat-subtitle mb-2">Terminal</h3>
<div className="h-[calc(100%-24px)]">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
@@ -1679,19 +1683,15 @@ export default function EditorLayout() {
<div className="p-4 border-b border-muted flex items-center justify-between">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
<TabsList className="bg-muted">
<TabsTrigger value="compose" className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{activeTab === 'compose' && (
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
)}
<span className="relative z-10">compose.yaml</span>
</TabsTrigger>
<TabsTrigger value="env" disabled={!envExists} className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{activeTab === 'env' && (
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
)}
<span className="relative z-10">.env</span>
</TabsTrigger>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="env">
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
@@ -1738,7 +1738,7 @@ export default function EditorLayout() {
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'env' && (
<div className="bg-blue-500/10 border-b border-blue-500/20 px-4 py-2 flex items-center gap-2 text-xs text-blue-400">
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
<span>
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
</span>
@@ -1762,6 +1762,7 @@ export default function EditorLayout() {
}}
options={{
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
+4 -4
View File
@@ -290,10 +290,10 @@ export default function FleetSnapshots() {
const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes);
if (skipped.length === 0) return null;
return (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm font-medium text-warning">
Some nodes were unreachable during snapshot creation:
</span>
</div>
@@ -519,7 +519,7 @@ export default function FleetSnapshots() {
<TableCell>
{skipped.length > 0 ? (
<span
className="flex items-center gap-1 text-amber-500"
className="flex items-center gap-1 text-warning"
title={`Skipped: ${skippedNames}`}
>
<AlertTriangle className="w-3.5 h-3.5" />
+31 -24
View File
@@ -11,7 +11,8 @@ import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
@@ -133,13 +134,13 @@ function StatCard({ icon: Icon, label, value, sub, alert }: {
alert?: boolean;
}) {
return (
<div className={`rounded-xl border bg-card p-4 ${alert ? 'border-red-500/30 bg-red-500/5' : ''}`}>
<div className={`rounded-lg border bg-card p-4 ${alert ? 'border-red-500/30 bg-red-500/5' : ''}`}>
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-4 h-4 ${alert ? 'text-red-500' : 'text-muted-foreground'}`} />
<span className="text-xs text-muted-foreground">{label}</span>
<Icon className={`w-4 h-4 ${alert ? 'text-red-500' : 'text-stat-icon'}`} />
<span className="text-xs text-stat-title">{label}</span>
</div>
<div className={`text-2xl font-bold ${alert ? 'text-red-500' : ''}`}>{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
<div className={`text-2xl font-medium tabular-nums tracking-tight ${alert ? 'text-destructive/70' : 'text-stat-value'}`}>{value}</div>
{sub && <p className="text-xs text-stat-subtitle mt-1">{sub}</p>}
</div>
);
}
@@ -154,8 +155,8 @@ function ContainerRow({ container, nodeId, onNavigate }: {
const image = container.Image;
const status = container.Status ?? '';
const stateColor = state === 'running' ? 'bg-emerald-500' :
state === 'restarting' ? 'bg-amber-500' : 'bg-red-500';
const stateColor = state === 'running' ? 'bg-success' :
state === 'restarting' ? 'bg-warning' : 'bg-red-500';
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
@@ -297,11 +298,11 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
<div className="p-4 pb-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-emerald-500/10' : 'bg-muted'}`}>
<Server className={`w-4 h-4 ${isOnline ? 'text-emerald-500' : 'text-muted-foreground'}`} />
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-success-muted' : 'bg-muted'}`}>
<Server className={`w-4 h-4 ${isOnline ? 'text-success' : 'text-muted-foreground'}`} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate">{node.name}</h3>
<h3 className="text-sm font-medium truncate">{node.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={isOnline ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0 h-4">
{isOnline ? (
@@ -327,15 +328,15 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
{isOnline && node.stats && (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
<div className="text-lg font-bold leading-none">{node.stats.active}</div>
<div className="text-lg font-medium leading-none tabular-nums">{node.stats.active}</div>
<div className="text-[10px] text-muted-foreground mt-1">Running</div>
</div>
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
<div className="text-lg font-bold leading-none">{node.stats.exited}</div>
<div className="text-lg font-medium leading-none tabular-nums">{node.stats.exited}</div>
<div className="text-[10px] text-muted-foreground mt-1">Stopped</div>
</div>
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
<div className="text-lg font-bold leading-none">{node.stacks?.length ?? '-'}</div>
<div className="text-lg font-medium leading-none tabular-nums">{node.stacks?.length ?? '-'}</div>
<div className="text-[10px] text-muted-foreground mt-1">Stacks</div>
</div>
</div>
@@ -351,7 +352,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
</span>
<span className="font-medium">{node.systemStats.cpu.usage}%</span>
</div>
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-amber-500' : 'bg-emerald-500'} />
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
</div>
<div>
<div className="flex items-center justify-between text-xs mb-1">
@@ -360,7 +361,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
</span>
<span className="font-medium">{formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}</span>
</div>
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-amber-500' : 'bg-blue-500'} />
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
</div>
{node.systemStats.disk && (
<div>
@@ -370,7 +371,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
</span>
<span className="font-medium">{formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}</span>
</div>
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-amber-500' : 'bg-violet-500'} />
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} />
</div>
)}
</div>
@@ -552,7 +553,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Fleet Overview</h1>
<h1 className="text-2xl font-medium tracking-tight">Fleet Overview</h1>
<p className="text-sm text-muted-foreground mt-1">
{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}
</p>
@@ -571,12 +572,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
{isPro && (
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
</TabsTrigger>
)}
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem>
{isPro && (
<TabsHighlightItem value="snapshots">
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
</TabsTrigger>
</TabsHighlightItem>
)}
</TabsHighlight>
</TabsList>
<TabsContent value="overview">
@@ -262,7 +262,7 @@ export function GlobalObservabilityView() {
</Button>
{devMode && (
<div className="flex items-center px-2 text-xs text-emerald-400 font-mono animate-pulse">
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
LIVE
</div>
)}
@@ -286,7 +286,7 @@ export function GlobalObservabilityView() {
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>{log.level}:</span>
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-success'}`}>{log.level}:</span>
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
</div>
))}
+51 -45
View File
@@ -71,6 +71,12 @@ export default function HomeDashboard() {
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const getValueColor = (value: number, warn = 80, crit = 90) => {
if (value >= crit) return 'text-destructive/80';
if (value >= warn) return 'text-warning/80';
return 'text-stat-value';
};
// Fetch container stats - re-runs when active node changes so stale data is cleared immediately
useEffect(() => {
setStats({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
@@ -214,43 +220,43 @@ export default function HomeDashboard() {
return (
<div className="flex-1 p-6 space-y-6">
{/* Container Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="rounded-xl border-muted bg-card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Containers</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
<CardTitle className="text-sm font-medium text-stat-title">Active Containers</CardTitle>
<Activity className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-500">{stats.active}</div>
<p className="text-xs text-muted-foreground mt-1">
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">{stats.active}</div>
<p className="text-xs text-stat-subtitle mt-1">
{stats.managed} managed · {stats.unmanaged} external
</p>
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Exited Containers</CardTitle>
<Square className="h-4 w-4 text-red-500" />
<CardTitle className="text-sm font-medium text-stat-title">Exited Containers</CardTitle>
<Square className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-500">{stats.exited}</div>
<p className="text-xs text-muted-foreground mt-1">Stopped or crashed</p>
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">{stats.exited}</div>
<p className="text-xs text-stat-subtitle mt-1">Stopped or crashed</p>
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Docker Network</CardTitle>
<Network className="h-4 w-4 text-cyan-500" />
<CardTitle className="text-sm font-medium text-stat-title">Docker Network</CardTitle>
<Network className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold text-cyan-500 whitespace-nowrap">
<div className="text-xl font-medium text-stat-value whitespace-nowrap">
{systemStats?.network
? `${formatBytes(systemStats.network.rxSec)}/s ↓`
: '...'}
</div>
<p className="text-xs text-muted-foreground mt-1 whitespace-nowrap">
<p className="text-xs text-stat-subtitle mt-1 whitespace-nowrap">
{systemStats?.network
? `${formatBytes(systemStats.network.txSec)}/s ↑`
: 'Loading...'}
@@ -260,32 +266,32 @@ export default function HomeDashboard() {
</div>
{/* Host System Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="rounded-xl border-muted bg-card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Host CPU</CardTitle>
<Cpu className="h-4 w-4 text-blue-500" />
<CardTitle className="text-sm font-medium text-stat-title">Host CPU</CardTitle>
<Cpu className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-500">
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">
{systemStats ? `${systemStats.cpu.usage}%` : '...'}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-stat-subtitle mt-1">
{systemStats ? `${systemStats.cpu.cores} cores` : 'Loading...'}
</p>
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Host RAM</CardTitle>
<MemoryStick className="h-4 w-4 text-purple-500" />
<CardTitle className="text-sm font-medium text-stat-title">Host RAM</CardTitle>
<MemoryStick className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-purple-500">
<div className={`text-3xl font-medium tabular-nums tracking-tight ${systemStats ? getValueColor(parseFloat(systemStats.memory.usagePercent)) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.memory.usagePercent}%` : '...'}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-stat-subtitle mt-1">
{systemStats
? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}`
: 'Loading...'}
@@ -293,16 +299,16 @@ export default function HomeDashboard() {
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Host Disk</CardTitle>
<HardDrive className="h-4 w-4 text-orange-500" />
<CardTitle className="text-sm font-medium text-stat-title">Host Disk</CardTitle>
<HardDrive className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-orange-500">
<div className={`text-3xl font-medium tabular-nums tracking-tight ${systemStats?.disk ? getValueColor(parseFloat(systemStats.disk.usagePercent)) : 'text-stat-value'}`}>
{systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-stat-subtitle mt-1">
{systemStats?.disk
? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}`
: 'Loading...'}
@@ -313,10 +319,10 @@ export default function HomeDashboard() {
{/* Historical Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<Activity className="w-4 h-4 text-primary" />
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" />
<span>Normalized CPU Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total CPU percentage over total host cores.</CardDescription>
@@ -325,9 +331,9 @@ export default function HomeDashboard() {
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
</AreaChart>
@@ -340,10 +346,10 @@ export default function HomeDashboard() {
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<Activity className="w-4 h-4 text-primary" />
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" />
<span>Normalized RAM Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total RAM allocation in GB.</CardDescription>
@@ -352,9 +358,9 @@ export default function HomeDashboard() {
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
</AreaChart>
@@ -369,7 +375,7 @@ export default function HomeDashboard() {
</div>
{/* Docker Run Converter */}
<Card className="rounded-xl border-muted bg-card">
<Card className="bg-card">
<CardHeader>
<CardTitle className="text-lg">Convert Docker Run to Compose</CardTitle>
<p className="text-sm text-muted-foreground">
+2 -2
View File
@@ -46,7 +46,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
cursorAccent: '#000000',
selectionBackground: 'rgba(255, 255, 255, 0.3)',
},
fontFamily: 'Consolas, "Courier New", monospace',
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
cursorBlink: true,
});
@@ -170,7 +170,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
</span>
)}
{isConnected && (
<span className="ml-2 text-xs bg-green-500/10 text-green-500 px-2 py-0.5 rounded-full border border-green-500/20">
<span className="ml-2 text-xs bg-success-muted text-success px-2 py-0.5 rounded-full border border-success/20">
Connected
</span>
)}
+1 -1
View File
@@ -61,7 +61,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi
<DialogHeader className="flex flex-row items-center gap-2 pb-2 border-b">
<Terminal className="w-5 h-5" />
<DialogTitle className="flex-1 text-left font-mono text-sm">
{containerName} {isConnected ? <span className="text-green-500 text-xs ml-2">(connected)</span> : <Loader2 className="inline w-3 h-3 ml-2 animate-spin" />}
{containerName} {isConnected ? <span className="text-success text-xs ml-2">(connected)</span> : <Loader2 className="inline w-3 h-3 ml-2 animate-spin" />}
</DialogTitle>
</DialogHeader>
+1 -1
View File
@@ -107,7 +107,7 @@ export function Login({
draggable={false}
/>
<div className="text-center">
<h1 className="text-4xl font-bold text-white tracking-tight">Sencho</h1>
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
</div>
</div>
+4 -4
View File
@@ -311,7 +311,7 @@ export function NodeManager() {
{/* Header */}
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<h2 className="text-lg font-medium flex items-center gap-2">
<Server className="w-5 h-5" />
Nodes
</h2>
@@ -380,7 +380,7 @@ export function NodeManager() {
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<code className="flex-1 text-xs font-mono truncate text-muted-foreground">{generatedToken}</code>
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={copyToken}>
{tokenCopied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
{tokenCopied ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
</Button>
</div>
)}
@@ -490,8 +490,8 @@ export function NodeManager() {
{/* Connection Test Result */}
{testResult && (
<div className="rounded-md border p-4 bg-muted/30 space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Wifi className="w-4 h-4 text-green-500" />
<h3 className="text-sm font-medium flex items-center gap-2">
<Wifi className="w-4 h-4 text-success" />
Connection Details - {nodes.find(n => n.id === testResult.nodeId)?.name}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
@@ -209,7 +209,7 @@ export function RegistriesSection() {
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Private Registries <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
@@ -365,7 +365,7 @@ export function RegistriesSection() {
<span>{reg.username}</span>
<span className="flex items-center gap-1">
{reg.has_secret ? (
<><CheckCircle className="w-3 h-3 text-emerald-500" /> Secret stored</>
<><CheckCircle className="w-3 h-3 text-success" /> Secret stored</>
) : (
<><XCircle className="w-3 h-3 text-destructive" /> No secret</>
)}
+44 -44
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from "@/components/ui/tabs";
import { springs } from '@/lib/motion';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -99,8 +100,8 @@ function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) {
const pct = (n: number) => `${Math.max(0, (n / total) * 100).toFixed(1)}%`;
const segments: { bytes: number; color: string; label: string; filter: ResourceFilter | null; hoverClass: string }[] = [
{ bytes: managedBytes, color: 'bg-emerald-500', label: 'Sencho Managed', filter: 'managed', hoverClass: 'hover:bg-emerald-400' },
{ bytes: unmanagedBytes, color: 'bg-orange-500', label: 'External Projects', filter: 'unmanaged', hoverClass: 'hover:bg-orange-400' },
{ bytes: managedBytes, color: 'bg-success', label: 'Sencho Managed', filter: 'managed', hoverClass: 'hover:bg-success/80' },
{ bytes: unmanagedBytes, color: 'bg-warning', label: 'External Projects', filter: 'unmanaged', hoverClass: 'hover:bg-warning/80' },
{ bytes: reclaimable, color: 'bg-muted-foreground/20', label: 'Reclaimable', filter: null, hoverClass: '' },
];
@@ -207,16 +208,16 @@ function ManagedBadge({ status, managedBy }: {
}) {
if (status === 'managed') {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-emerald-500/25 bg-emerald-500/8 text-emerald-500 text-[10px] font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />
{managedBy}
</span>
);
}
if (status === 'unmanaged') {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-orange-500/25 bg-orange-500/8 text-orange-500 text-[10px] font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 shrink-0" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-warning/25 bg-warning/8 text-warning text-[10px] font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-warning shrink-0" />
External
</span>
);
@@ -256,7 +257,7 @@ function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: Pru
<span className={cn('transition-transform duration-200 group-hover:scale-110', accentClass)}>
{icon}
</span>
<span className="text-xs font-semibold text-center leading-tight text-foreground">{label}</span>
<span className="text-xs font-medium text-center leading-tight text-foreground">{label}</span>
<span className="text-[10px] text-brand font-mono tracking-wide">Sencho only</span>
</button>
{target !== 'containers' && (
@@ -451,7 +452,7 @@ export default function ResourcesView() {
{/* Header */}
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">Resources Hub</h1>
<h1 className="text-xl font-medium tracking-tight">Resources Hub</h1>
{activeNode?.type === 'remote' && (
<span className="text-sm text-muted-foreground">- {activeNode.name}</span>
)}
@@ -519,7 +520,7 @@ export default function ResourcesView() {
target="networks"
icon={<Network className="w-6 h-6" />}
label="Prune Dead Networks"
accentClass="text-emerald-500"
accentClass="text-success"
onManaged={() => setConfirmPrune({ target: 'networks', scope: 'managed' })}
onAll={() => setConfirmPrune({ target: 'networks', scope: 'all' })}
/>
@@ -527,7 +528,7 @@ export default function ResourcesView() {
target="containers"
icon={<MonitorX className="w-6 h-6" />}
label="Purge Unmanaged Containers"
accentClass="text-orange-500"
accentClass="text-warning"
onManaged={() => setConfirmPrune({ target: 'containers', scope: 'managed' })}
onAll={() => setConfirmPrune({ target: 'containers', scope: 'all' })}
/>
@@ -541,28 +542,27 @@ export default function ResourcesView() {
defaultValue="images"
className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-sm overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150"
>
<div className="px-4 pt-3 pb-0 border-b bg-muted/10">
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 bg-transparent gap-1 p-0">
{(['images', 'volumes', 'networks'] as const).map(tab => (
<TabsTrigger
key={tab}
value={tab}
className="capitalize text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-md rounded-b-none border-b-2 border-transparent data-[state=active]:border-foreground transition-all duration-200"
>
{tab}
</TabsTrigger>
))}
<TabsTrigger
value="unmanaged"
className="relative text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-md rounded-b-none border-b-2 border-transparent data-[state=active]:border-foreground transition-all duration-200"
>
Unmanaged
{totalOrphansCount > 0 && (
<span className="absolute -top-1.5 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-orange-500 text-[9px] text-white font-bold animate-in zoom-in-75 duration-200">
{totalOrphansCount}
</span>
)}
</TabsTrigger>
<div className="px-4 pt-3 pb-0 border-b border-glass-border bg-glass">
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
{(['images', 'volumes', 'networks'] as const).map(tab => (
<TabsHighlightItem key={tab} value={tab}>
<TabsTrigger value={tab} className="capitalize text-xs">
{tab}
</TabsTrigger>
</TabsHighlightItem>
))}
<TabsHighlightItem value="unmanaged">
<TabsTrigger value="unmanaged" className="relative text-xs">
Unmanaged
{totalOrphansCount > 0 && (
<span className="absolute -top-1.5 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-warning text-[9px] text-warning-foreground font-medium animate-in zoom-in-75 duration-200">
{totalOrphansCount}
</span>
)}
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</div>
@@ -726,7 +726,7 @@ export default function ResourcesView() {
{/* Unmanaged Containers */}
<TabsContent value="unmanaged" className="m-0 border-0 p-0 h-full flex flex-col animate-in fade-in-0 duration-200">
<div className="flex justify-between items-center px-4 py-2.5 border-b bg-muted/10 sticky top-0 z-10 backdrop-blur-sm">
<div className="flex justify-between items-center px-4 py-2.5 border-b bg-muted/10 sticky top-0 z-10">
<div className="flex items-center gap-2.5">
<input
type="checkbox"
@@ -750,8 +750,8 @@ export default function ResourcesView() {
{totalOrphansCount === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-muted-foreground animate-in fade-in-0 duration-300">
<div className="w-12 h-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-3">
<ShieldCheck className="w-6 h-6 text-emerald-500" />
<div className="w-12 h-12 rounded-full bg-success-muted flex items-center justify-center mb-3">
<ShieldCheck className="w-6 h-6 text-success" />
</div>
<p className="font-medium text-sm">No unmanaged containers</p>
<p className="text-xs mt-1 opacity-70">All running containers are managed by Sencho.</p>
@@ -765,9 +765,9 @@ export default function ResourcesView() {
style={{ animationDelay: `${gi * 60}ms` }}
>
{/* Project header */}
<div className="bg-orange-500/8 border-b border-orange-500/15 px-4 py-2 font-medium text-xs flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse shrink-0" />
<span className="text-orange-600 dark:text-orange-400">External Project:</span>
<div className="bg-warning/8 border-b border-warning/15 px-4 py-2 font-medium text-xs flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-warning animate-pulse shrink-0" />
<span className="text-warning">External Project:</span>
<span className="font-mono text-foreground">{project}</span>
<span className="ml-auto text-muted-foreground font-normal">{containers.length} container{containers.length !== 1 ? 's' : ''}</span>
</div>
@@ -785,7 +785,7 @@ export default function ResourcesView() {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold truncate">
<span className="font-mono text-xs font-medium truncate">
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)}
</span>
<Badge
@@ -823,8 +823,8 @@ export default function ResourcesView() {
Prune All Docker {confirmPrune?.target}
</AlertDialogTitle>
<AlertDialogDescription>
This will prune <span className="font-semibold text-foreground">all</span> unused {confirmPrune?.target} from the Docker daemon -
including those from <span className="font-semibold text-foreground">external projects not managed by Sencho</span>. This cannot be undone.
This will prune <span className="font-medium text-foreground">all</span> unused {confirmPrune?.target} from the Docker daemon -
including those from <span className="font-medium text-foreground">external projects not managed by Sencho</span>. This cannot be undone.
</AlertDialogDescription>
</>
) : (
@@ -832,7 +832,7 @@ export default function ResourcesView() {
<AlertDialogTitle>Prune Sencho-Managed {confirmPrune?.target}</AlertDialogTitle>
<AlertDialogDescription>
Only unused {confirmPrune?.target} belonging to your Sencho stacks will be removed.
External Docker resources are <span className="font-semibold text-foreground">not affected</span>.
External Docker resources are <span className="font-medium text-foreground">not affected</span>.
</AlertDialogDescription>
</>
)}
@@ -856,7 +856,7 @@ export default function ResourcesView() {
<AlertDialogHeader>
<AlertDialogTitle>Delete {confirmDelete?.type.slice(0, -1)}</AlertDialogTitle>
<AlertDialogDescription>
Permanently delete <span className="font-mono font-bold text-foreground">{confirmDelete?.name || confirmDelete?.id.substring(0, 12)}</span>? This cannot be undone.
Permanently delete <span className="font-mono font-medium text-foreground">{confirmDelete?.name || confirmDelete?.id.substring(0, 12)}</span>? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+3 -3
View File
@@ -127,7 +127,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
<div className="flex items-center gap-3">
<span className="font-medium text-sm">{label}</span>
{initialConfig?.enabled && (
<Badge variant="secondary" className="text-xs bg-green-500/10 text-green-500 border-green-500/20">
<Badge variant="secondary" className="text-xs bg-success-muted text-success border-success/20">
Active
</Badge>
)}
@@ -298,7 +298,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
</Button>
{testResult && (
testResult.success
? <CheckCircle className="w-4 h-4 text-green-500" />
? <CheckCircle className="w-4 h-4 text-success" />
: <XCircle className="w-4 h-4 text-red-500" />
)}
</div>
@@ -333,7 +333,7 @@ export function SSOSection() {
<AdmiralGate featureName="SSO Authentication">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
<Shield className="w-5 h-5" />
SSO Authentication <TierBadge />
</h3>
@@ -340,7 +340,7 @@ export default function ScheduledOperationsView() {
</TableCell>
<TableCell>
{task.last_status === 'success' ? (
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : task.last_status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
@@ -541,7 +541,7 @@ export default function ScheduledOperationsView() {
</TableCell>
<TableCell>
{run.status === 'success' ? (
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : run.status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -93,7 +93,7 @@ export function Setup({
draggable={false}
/>
<div className="text-center">
<h1 className="text-4xl font-bold text-white tracking-tight">Sencho</h1>
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
</div>
</div>
+15 -15
View File
@@ -183,22 +183,22 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
if (isRemote) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div className="flex items-start gap-2 p-3 rounded-lg bg-info-muted border border-info/20 text-sm">
<Info className="h-4 w-4 text-info shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="font-medium text-blue-700 dark:text-blue-400">
Remote node: <span className="font-semibold">{activeNode?.name}</span>
<p className="font-medium text-info">
Remote node: <span className="font-medium">{activeNode?.name}</span>
</p>
<p className="text-muted-foreground">
Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.
</p>
{!agentStatus.hasEnabled && (
<p className="text-amber-600 dark:text-amber-400 font-medium mt-1">
<p className="text-warning font-medium mt-1">
No notification channels are configured on this remote node. Open Settings → Notifications to configure them.
</p>
)}
{agentStatus.hasEnabled && (
<p className="text-green-600 dark:text-green-400 font-medium mt-1">
<p className="text-success font-medium mt-1">
Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
</p>
)}
@@ -209,10 +209,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
if (!agentStatus.hasEnabled) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning-muted border border-warning/20 text-sm">
<AlertTriangle className="h-4 w-4 text-warning shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-700 dark:text-amber-400">No notification channels configured</p>
<p className="font-medium text-warning">No notification channels configured</p>
<p className="text-muted-foreground mt-0.5">
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '}
<span className="font-medium">Settings → Notifications</span>.
@@ -223,10 +223,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
}
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<div className="flex items-center gap-2 p-3 rounded-lg bg-success-muted border border-success/20 text-sm">
<CheckCircle2 className="h-4 w-4 text-success shrink-0" />
<div>
<p className="font-medium text-green-700 dark:text-green-400">
<p className="font-medium text-success">
Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
</p>
</div>
@@ -251,7 +251,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
{/* List Existing Alerts */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Existing Rules</h4>
<h4 className="text-sm font-medium">Existing Rules</h4>
{alerts.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
No active alert rules for this stack.
@@ -261,7 +261,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
<div className="flex justify-between items-start">
<div>
<span className="font-semibold text-foreground">
<span className="font-medium text-foreground">
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
</span>
<div className="text-muted-foreground mt-1">
@@ -287,7 +287,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
{/* Add New Alert Form */}
{isAdmin && <div className="space-y-4">
<h4 className="text-sm font-semibold">Add New Rule</h4>
<h4 className="text-sm font-medium">Add New Rule</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
+1 -1
View File
@@ -77,7 +77,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
brightCyan: '#56d4dd',
brightWhite: '#ffffff',
},
fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace",
fontFamily: "'Geist Mono', monospace",
fontSize: 13,
scrollback: 10000,
});
@@ -0,0 +1,51 @@
import { Badge } from '@/components/ui/badge';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
export function AboutSection() {
const { license } = useLicense();
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>
<Badge variant="secondary" className="font-mono">v{__APP_VERSION__}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tier</span>
<div><TierBadge /></div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">License Status</span>
<Badge variant="outline" className="capitalize">{license?.status ?? 'community'}</Badge>
</div>
{license?.instanceId && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Instance ID</span>
<code className="text-xs font-mono bg-muted px-2 py-1 rounded">{license.instanceId.slice(0, 8)}</code>
</div>
)}
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Links</h4>
<div className="flex flex-col gap-1.5">
<a
href="https://github.com/AnsoCode/Sencho/blob/main/CHANGELOG.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog &rarr;
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RefreshCw } from 'lucide-react';
interface AccountSectionProps {
authData: { oldPassword: string; newPassword: string; confirmPassword: string };
onAuthDataChange: (data: { oldPassword: string; newPassword: string; confirmPassword: string }) => void;
onPasswordChange: () => Promise<void>;
isSaving: boolean;
}
export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) {
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>
<Input
type="password"
value={authData.oldPassword}
onChange={(e) => onAuthDataChange({ ...authData, oldPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>New Password</Label>
<Input
type="password"
value={authData.newPassword}
onChange={(e) => onAuthDataChange({ ...authData, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Confirm New Password</Label>
<Input
type="password"
value={authData.confirmPassword}
onChange={(e) => onAuthDataChange({ ...authData, confirmPassword: e.target.value })}
/>
</div>
<Button onClick={onPasswordChange} disabled={isSaving} className="w-full">
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
: 'Update Password'
}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { RefreshCw } from 'lucide-react';
import type { PatchableSettings } from './types';
interface AppStoreSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
isLoading: boolean;
/** Called after a successful save so the parent can update serverSettingsRef */
onSaved: (key: keyof PatchableSettings, value: string) => void;
}
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>
);
}
export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved }: AppStoreSectionProps) {
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
const saveRegistrySettings = async () => {
setIsSavingRegistry(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
return;
}
onSaved('template_registry_url', settings.template_registry_url ?? '');
await apiFetch('/templates/refresh-cache', { method: 'POST' });
toast.success('Registry saved. App Store will reload from the new source.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to save registry settings.');
} finally {
setIsSavingRegistry(false);
}
};
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">
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
<div className="space-y-3 pt-4 border-t border-glass-border">
<div className="space-y-1">
<Label className="text-base">Custom Registry URL</Label>
<p className="text-xs text-muted-foreground">
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
</p>
</div>
<Input
placeholder="https://example.com/templates.json"
value={settings.template_registry_url ?? ''}
onChange={(e) => onSettingChange('template_registry_url', e.target.value)}
/>
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
</div>
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => onSettingChange('template_registry_url', '')}
disabled={isSavingRegistry || !settings.template_registry_url}
>
Reset to Default
</Button>
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
{isSavingRegistry
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save & Refresh'
}
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,180 @@
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 { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useLicense } from '@/context/LicenseContext';
import { RefreshCw, Database, Info } from 'lucide-react';
import type { PatchableSettings } from './types';
interface DeveloperSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
isSaving: boolean;
isLoading: boolean;
isRemote: boolean;
}
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>
);
}
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
const { isPro, license } = useLicense();
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 && (
<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" />
Always Local
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
These settings control this Sencho instance's UI behaviour and are never synced to remote nodes.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
</div>
<Switch
id="developer_mode"
checked={settings.developer_mode === '1'}
onCheckedChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
<div className="space-y-2 pt-4 border-t border-glass-border">
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>
Standard Log Polling Rate
</Label>
<Select
value={settings.global_logs_refresh}
onValueChange={(val) => onSettingChange('global_logs_refresh', val as '1' | '3' | '5' | '10')}
disabled={settings.developer_mode === '1'}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="Select rate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 second</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
<SelectItem value="10">10 seconds</SelectItem>
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-warning">SSE streaming is active - polling rate is overridden.</p>
)}
</div>
</div>
{/* Data Retention (Observability) */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Data Retention</span>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-base">Container Metrics Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep per-container CPU/RAM/network history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">hrs</span>
</div>
</div>
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Notification Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep alert and notification history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
{isPro && license?.variant === 'team' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep audit trail entries.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
)}
</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 Developer Settings'
}
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import {
Crown, CheckCircle, Check, XCircle, Clock, ExternalLink,
CreditCard, RefreshCw, Zap, Compass, ShipWheel,
} from 'lucide-react';
export function LicenseSection() {
const { license, activate, deactivate } = useLicense();
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [isActivating, setIsActivating] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
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 Pro 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">
<div className="flex items-center gap-2">
{license?.tier === 'pro' ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<Crown className="w-5 h-5 text-muted-foreground" />
)}
<span className="font-medium text-base">
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
</span>
</div>
<TierBadge />
</div>
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining</span>
</div>
)}
{license?.status === 'active' && (
<div className="space-y-2 text-sm">
{license.customerName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Customer</span>
<span>{license.customerName}</span>
</div>
)}
{license.productName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span>{license.productName}</span>
</div>
)}
{license.maskedKey && (
<div className="flex justify-between">
<span className="text-muted-foreground">License Key</span>
<span className="font-mono text-xs">{license.maskedKey}</span>
</div>
)}
{license.validUntil && (
<div className="flex justify-between">
<span className="text-muted-foreground">Renews</span>
<span>{new Date(license.validUntil).toLocaleDateString()}</span>
</div>
)}
</div>
)}
{license?.status === 'expired' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your Pro license has expired. Renew to restore Pro features.</span>
</div>
)}
{license?.status === 'disabled' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your license has been disabled. Contact support for assistance.</span>
</div>
)}
</div>
{/* Manage Subscription (active Pro) */}
{license?.status === 'active' && (
<div className="space-y-3">
{license.portalUrl && (
<Button
variant="outline"
size="sm"
onClick={() => window.open(license.portalUrl!, '_blank')}
>
<CreditCard className="w-4 h-4 mr-2" />
Manage Subscription
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
)}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Deactivating will revert to Community features.
</p>
<Button
variant="outline"
size="sm"
onClick={async () => {
setIsDeactivating(true);
const result = await deactivate();
if (result.success) {
toast.success('License deactivated.');
} else {
toast.error(result.error || 'Deactivation failed');
}
setIsDeactivating(false);
}}
disabled={isDeactivating}
>
{isDeactivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Deactivating...</>
: 'Deactivate License'
}
</Button>
</div>
</div>
)}
{/* Upgrade Cards */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users */}
{license?.tier !== 'pro' && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<Compass className="w-4 h-4 text-amber-500" />
<span className="font-medium text-sm">Skipper</span>
<Badge variant="secondary" className="text-[10px] font-medium uppercase px-1.5 py-0">Popular</Badge>
</div>
<p className="text-xs text-muted-foreground">Professional tools for solo operators.</p>
<ul className="space-y-1.5">
{['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Skipper
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
{/* Admiral Card */}
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Admiral</span>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(license?.variant === 'personal' ? ['Everything in Skipper'] : ['Everything in Community']),
'Unlimited admin accounts',
'Unlimited viewer accounts',
...(license?.variant !== 'personal' ? ['Fleet View & webhooks', 'Atomic deployment & backups'] : []),
'Team onboarding assistance',
].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Admiral
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
</div>
</div>
)}
{/* License key activation */}
{license?.status !== 'active' && (
<div className="border-t border-glass-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
<div className="flex gap-2">
<Input
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
/>
<Button
variant="outline"
onClick={async () => {
if (!licenseKeyInput.trim()) return;
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
}
setIsActivating(false);
}}
disabled={isActivating || !licenseKeyInput.trim()}
>
{isActivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Activating...</>
: 'Activate'
}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
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 'sonner';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { RefreshCw, Info } 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>>({
discord: { type: 'discord', url: '', enabled: false },
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
});
const [isSavingAgent, setIsSavingAgent] = useState<Record<string, boolean>>({});
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
const fetchAgents = async () => {
try {
const res = await apiFetch('/agents');
if (res.ok) {
const data: Agent[] = await res.json();
setAgents(prev => {
const next = { ...prev };
data.forEach(a => { next[a.type] = a; });
return next;
});
}
} catch (e) {
console.error('Failed to fetch agents', e);
}
};
useEffect(() => { fetchAgents(); }, [activeNode?.id]);
const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => {
setAgents(prev => ({
...prev,
[type]: { ...prev[type], [field]: value },
}));
};
const saveAgent = async (type: string) => {
setIsSavingAgent(prev => ({ ...prev, [type]: true }));
try {
const res = await apiFetch('/agents', {
method: 'POST',
body: JSON.stringify(agents[type]),
});
if (res.ok) {
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`);
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Something went wrong.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setIsSavingAgent(prev => ({ ...prev, [type]: false }));
}
};
const testAgent = async (type: string) => {
if (!agents[type].url) {
toast.error('Please enter a webhook URL first.');
return;
}
setIsTestingAgent(prev => ({ ...prev, [type]: true }));
try {
const res = await apiFetch('/notifications/test', {
method: 'POST',
body: JSON.stringify({ type, url: agents[type].url }),
});
if (res.ok) {
toast.success('Test notification sent!');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.details || err?.error || 'Test failed.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setIsTestingAgent(prev => ({ ...prev, [type]: false }));
}
};
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${type}-enabled`} className="font-medium">Enable {title}</Label>
<Switch
id={`${type}-enabled`}
checked={agents[type].enabled}
onCheckedChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-url`}>Webhook URL</Label>
<Input
id={`${type}-url`}
placeholder="https://..."
value={agents[type].url}
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
/>
</div>
<div className="flex space-x-2 justify-end pt-4">
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
{isTestingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Testing...</> : 'Test'}
</Button>
<Button onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
{isSavingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</> : 'Save'}
</Button>
</div>
</div>
);
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}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="slack">
<TabsTrigger value="slack">Slack</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="webhook">
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
export function SupportSection() {
const { isPro, license } = useLicense();
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>
<div className="grid gap-3">
<a href="https://docs.sencho.io" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Book className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documentation</p>
<p className="text-xs text-muted-foreground">Guides, reference, and tutorials</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
<a href="https://github.com/AnsoCode/Sencho/issues" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Bug className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">GitHub Issues</p>
<p className="text-xs text-muted-foreground">Report bugs and request features</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
</div>
</div>
{/* Pro support channels */}
{isPro && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
Pro Support <TierBadge />
</h4>
<div className="grid gap-3">
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mail className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{license?.variant === 'team' ? 'Priority Email Support' : 'Email Support'}
</p>
<p className="text-xs text-muted-foreground">
{license?.variant === 'team'
? 'Direct support with responses within 24 hours'
: 'Reach our support team directly'}
</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
</div>
</div>
)}
{/* Upsell for Community */}
{!isPro && (
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
<div className="flex items-start gap-3">
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Need faster support?</p>
<p className="text-xs text-muted-foreground mt-1">
Upgrade to Pro for direct email support and priority issue handling.
</p>
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
Upgrade to Pro
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,131 @@
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 type { PatchableSettings } from './types';
interface SystemSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
isSaving: boolean;
isLoading: boolean;
isRemote: boolean;
activeNodeName?: string;
}
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>
);
}
export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote, activeNodeName }: SystemSectionProps) {
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>
{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>
);
}
@@ -0,0 +1,457 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from '@/components/ProGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
interface UserItem {
id: number;
username: string;
role: UserRole;
created_at: number;
}
interface RoleAssignmentItem {
id: number;
user_id: number;
role: UserRole;
resource_type: 'stack' | 'node';
resource_id: string;
created_at: number;
}
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPro, license } = useLicense();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formUsername, setFormUsername] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formConfirmPassword, setFormConfirmPassword] = useState('');
const [formRole, setFormRole] = useState<UserRole>('viewer');
const fetchUsers = async () => {
try {
const res = await apiFetch('/users', { localOnly: true });
if (res.ok) setUsers(await res.json());
} catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { fetchUsers(); }, []);
const resetForm = () => {
setFormUsername('');
setFormPassword('');
setFormConfirmPassword('');
setFormRole('viewer');
setEditingUser(null);
setShowForm(false);
};
const handleSave = async () => {
if (!formUsername || formUsername.length < 3) {
toast.error('Username must be at least 3 characters.');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) {
toast.error('Username can only contain letters, numbers, underscores, and hyphens.');
return;
}
if (!editingUser && !formPassword) {
toast.error('Password is required for new users.');
return;
}
if (formPassword && formPassword.length < 6) {
toast.error('Password must be at least 6 characters.');
return;
}
if (formPassword && formPassword !== formConfirmPassword) {
toast.error('Passwords do not match.');
return;
}
setSaving(true);
try {
if (editingUser) {
const body: Record<string, string> = { username: formUsername, role: formRole };
if (formPassword) body.password = formPassword;
const res = await apiFetch(`/users/${editingUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to update user.');
return;
}
toast.success('User updated.');
} else {
const res = await apiFetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to create user.');
return;
}
toast.success('User created.');
}
resetForm();
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleDelete = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to delete user.');
return;
}
toast.success('User deleted.');
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
const startEdit = (u: UserItem) => {
setEditingUser(u);
setFormUsername(u.username);
setFormRole(u.role);
setFormPassword('');
setFormConfirmPassword('');
setShowForm(true);
fetchRoleAssignments(u.id);
fetchScopeResources();
};
// --- Scoped Role Assignments ---
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
const [scopeResourceId, setScopeResourceId] = useState('');
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
const [addingScope, setAddingScope] = useState(false);
const fetchRoleAssignments = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}/roles`, { localOnly: true });
if (res.ok) setRoleAssignments(await res.json());
else setRoleAssignments([]);
} catch { setRoleAssignments([]); }
};
const fetchScopeResources = async () => {
try {
const [stacksRes, nodesRes] = await Promise.all([
apiFetch('/stacks', { localOnly: true }),
apiFetch('/nodes', { localOnly: true }),
]);
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
}
if (nodesRes.ok) {
const data = await nodesRes.json();
setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []);
}
} catch { /* ignore */ }
};
const addRoleAssignment = async () => {
if (!editingUser || !scopeResourceId) return;
setAddingScope(true);
try {
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to add scope.');
return;
}
toast.success('Scope added.');
setScopeResourceId('');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally { setAddingScope(false); }
};
const removeRoleAssignment = async (assignId: number) => {
if (!editingUser) return;
try {
const res = await apiFetch(`/users/${editingUser.id}/roles/${assignId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to remove scope.');
return;
}
toast.success('Scope removed.');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
return (
<ProGate 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 && (
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1" />Add User
</Button>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<h4 className="text-sm font-medium">{editingUser ? 'Edit User' : 'New User'}</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Username</Label>
<Input
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={formRole} onValueChange={(v) => setFormRole(v as UserRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPro && license?.variant === 'team' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'}
/>
</div>
<div className="space-y-2">
<Label>Confirm Password</Label>
<Input
type="password"
value={formConfirmPassword}
onChange={(e) => setFormConfirmPassword(e.target.value)}
placeholder="Confirm password"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
</Button>
</div>
{/* Scoped Permissions (Admiral, editing only) */}
{editingUser && isPro && license?.variant === 'team' && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
Grant additional permissions on specific stacks or nodes. These supplement the user's global role.
</p>
{roleAssignments.length > 0 && (
<div className="space-y-1">
{roleAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm bg-muted/50 rounded px-3 py-1.5">
<span>
<Badge variant="outline" className="text-xs mr-2 capitalize">{a.role}</Badge>
on <span className="font-medium capitalize">{a.resource_type}</span>: <span className="font-mono text-xs">{a.resource_id}</span>
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeRoleAssignment(a.id)}>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="space-y-1">
<Label className="text-xs">Role</Label>
<Select value={scopeRole} onValueChange={(v) => setScopeRole(v as UserRole)}>
<SelectTrigger className="h-8 text-xs w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Resource Type</Label>
<Select value={scopeResourceType} onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stack">Stack</SelectItem>
<SelectItem value="node">Node</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex-1">
<Label className="text-xs">Resource</Label>
<Select value={scopeResourceId} onValueChange={setScopeResourceId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{scopeResourceType === 'stack' ? (
availableStacks.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))
) : (
availableNodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
</div>
</div>
)}
</div>
)}
{/* Users Table */}
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">No users found.</div>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={isSelf}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The user will lose access immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</ProGate>
);
}
@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { TierBadge } from '@/components/TierBadge';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
} from 'lucide-react';
interface WebhookItem {
id: number;
name: string;
stack_name: string;
action: string;
secret: string;
enabled: boolean;
created_at: number;
updated_at: number;
}
interface WebhookExecution {
id: number;
webhook_id: number;
action: string;
status: 'success' | 'failure';
trigger_source: string | null;
duration_ms: number | null;
error: string | null;
executed_at: number;
}
export function WebhooksSection({ isPro }: { isPro: boolean }) {
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [showForm, setShowForm] = useState(false);
const [newSecret, setNewSecret] = useState<{ id: number; secret: string } | null>(null);
const [expandedHistory, setExpandedHistory] = useState<number | null>(null);
const [history, setHistory] = useState<Record<number, WebhookExecution[]>>({});
const [loadingHistory, setLoadingHistory] = useState<number | null>(null);
// Form state
const [formName, setFormName] = useState('');
const [formStack, setFormStack] = useState('');
const [formAction, setFormAction] = useState<string>('deploy');
const [stacks, setStacks] = useState<string[]>([]);
const fetchWebhooks = async () => {
try {
const res = await apiFetch('/webhooks', { localOnly: true });
if (res.ok) setWebhooks(await res.json());
} catch { /* ignore */ } finally { setLoading(false); }
};
const fetchStacks = async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) setStacks(await res.json());
} catch { /* ignore */ }
};
useEffect(() => { fetchWebhooks(); fetchStacks(); }, []);
const handleCreate = async () => {
if (!formName || !formStack || !formAction) {
toast.error('All fields are required.');
return;
}
setCreating(true);
try {
const res = await apiFetch('/webhooks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }),
});
if (res.ok) {
const data = await res.json();
setNewSecret({ id: data.id, secret: data.secret });
setShowForm(false);
setFormName(''); setFormStack(''); setFormAction('deploy');
fetchWebhooks();
toast.success('Webhook created.');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to create webhook.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally { setCreating(false); }
};
const handleDelete = async (id: number) => {
try {
const res = await apiFetch(`/webhooks/${id}`, { method: 'DELETE', localOnly: true });
if (res.ok) { toast.success('Webhook deleted.'); fetchWebhooks(); }
else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete.'); }
} catch { toast.error('Network error.'); }
};
const handleToggle = async (id: number, enabled: boolean) => {
try {
const res = await apiFetch(`/webhooks/${id}`, {
method: 'PUT', localOnly: true,
body: JSON.stringify({ enabled }),
});
if (res.ok) fetchWebhooks();
} catch { /* ignore */ }
};
const fetchHistory = async (webhookId: number) => {
if (expandedHistory === webhookId) { setExpandedHistory(null); return; }
setExpandedHistory(webhookId);
setLoadingHistory(webhookId);
try {
const res = await apiFetch(`/webhooks/${webhookId}/history`, { localOnly: true });
if (res.ok) {
const data = await res.json();
setHistory(prev => ({ ...prev, [webhookId]: data }));
}
} catch { /* ignore */ } finally { setLoadingHistory(null); }
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard.`);
};
if (!isPro) {
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>
<ProGate featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</ProGate>
</div>
);
}
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>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" /> Create Webhook
</Button>
</div>
{/* Create Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Stack</Label>
<Select value={formStack} onValueChange={setFormStack}>
<SelectTrigger><SelectValue placeholder="Select a stack..." /></SelectTrigger>
<SelectContent>
{stacks.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select value={formAction} onValueChange={setFormAction}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deploy">Deploy (down + up)</SelectItem>
<SelectItem value="restart">Restart</SelectItem>
<SelectItem value="stop">Stop</SelectItem>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="pull">Pull & Update</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
</Button>
</div>
</div>
)}
{/* Secret reveal (shown once after creation) */}
{newSecret && (
<div className="bg-success-muted border border-success/30 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-success">
<CheckCircle className="w-4 h-4" /> Webhook created - copy your secret now
</div>
<p className="text-xs text-muted-foreground">This secret will not be shown again. Store it securely.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all">{newSecret.secret}</code>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(newSecret.secret, 'Secret')}>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => setNewSecret(null)}>Dismiss</Button>
</div>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{/* Empty state */}
{!loading && webhooks.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Webhook className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No webhooks configured yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to trigger stack actions from CI/CD.</p>
</div>
)}
{/* Webhook list */}
{!loading && webhooks.map(wh => {
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
const isExpanded = expandedHistory === wh.id;
return (
<div key={wh.id} className="border border-glass-border rounded-lg overflow-hidden">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Webhook className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{wh.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{wh.action}</Badge>
<Badge variant="secondary" className="text-[10px] shrink-0">{wh.stack_name}</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={wh.enabled} onCheckedChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
</div>
{/* Trigger URL */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Trigger URL</Label>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-muted px-2.5 py-1.5 rounded-md truncate">{triggerUrl}</code>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => copyToClipboard(triggerUrl, 'URL')}>
<Copy className="w-3 h-3" />
</Button>
</div>
</div>
{/* Secret (masked) */}
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Secret:</span>
<code className="font-mono text-muted-foreground">{wh.secret}</code>
</div>
{/* History toggle */}
<button
onClick={() => fetchHistory(wh.id!)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<History className="w-3 h-3" />
Recent executions
</button>
</div>
{/* Execution history */}
{isExpanded && (
<div className="border-t bg-muted/20 px-4 py-3">
{loadingHistory === wh.id ? (
<Skeleton className="h-8 w-full" />
) : (history[wh.id!] ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No executions yet.</p>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(history[wh.id!] ?? []).map(ex => (
<div key={ex.id} className="flex items-center gap-2 text-xs">
{ex.status === 'success'
? <CheckCircle className="w-3 h-3 text-success shrink-0" />
: <XCircle className="w-3 h-3 text-red-500 shrink-0" />}
<span className="font-medium">{ex.action}</span>
<span className="text-muted-foreground">
{new Date(ex.executed_at).toLocaleString()}
</span>
{ex.duration_ms !== null && (
<span className="text-muted-foreground">{(ex.duration_ms / 1000).toFixed(1)}s</span>
)}
{ex.error && (
<span className="text-red-500 truncate" title={ex.error}>{ex.error}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { AccountSection } from './AccountSection';
export { LicenseSection } from './LicenseSection';
export { UsersSection } from './UsersSection';
export { SystemSection } from './SystemSection';
export { NotificationsSection } from './NotificationsSection';
export { WebhooksSection } from './WebhooksSection';
export { DeveloperSection } from './DeveloperSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
export { DEFAULT_SETTINGS } from './types';
export type { PatchableSettings, SectionId, Agent } from './types';
+49
View File
@@ -0,0 +1,49 @@
export interface PatchableSettings {
host_cpu_limit?: string;
host_ram_limit?: string;
host_disk_limit?: string;
docker_janitor_gb?: string;
global_crash?: '0' | '1';
global_logs_refresh?: '1' | '3' | '5' | '10';
developer_mode?: '0' | '1';
template_registry_url?: string;
metrics_retention_hours?: string;
log_retention_days?: string;
audit_retention_days?: string;
}
export const DEFAULT_SETTINGS: PatchableSettings = {
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5',
global_logs_refresh: '5',
developer_mode: '0',
template_registry_url: '',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
};
export type SectionId =
| 'account'
| 'license'
| 'users'
| 'sso'
| 'api-tokens'
| 'registries'
| 'system'
| 'notifications'
| 'webhooks'
| 'developer'
| 'nodes'
| 'appstore'
| 'support'
| 'about';
export interface Agent {
type: 'discord' | 'slack' | 'webhook';
url: string;
enabled: boolean;
}
+3 -3
View File
@@ -14,7 +14,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@@ -32,7 +32,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-glass-border bg-popover p-6 shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] sm:rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
@@ -72,7 +72,7 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
className={cn('text-lg font-medium', className)}
{...props}
/>
));
+3 -3
View File
@@ -14,10 +14,10 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background shadow-btn-glow hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-glass-highlight",
ghost: "hover:bg-glass-highlight hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
+2 -2
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover",
className
)}
{...props}
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
className={cn("font-medium leading-none tracking-tight", className)}
{...props}
/>
))
+3 -3
View File
@@ -44,7 +44,7 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
@@ -60,7 +60,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
@@ -142,7 +142,7 @@ const ContextMenuLabel = React.forwardRef<
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
"px-2 py-1.5 text-sm font-medium text-foreground",
inset && "pl-8",
className
)}
+2 -2
View File
@@ -23,11 +23,11 @@ const DialogContent = React.forwardRef<
React.ComponentProps<typeof AnimateDialogContent>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/80" />
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<AnimateDialogContent
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-glass-border bg-popover p-6 shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] sm:rounded-lg',
className
)}
{...props}
+3 -3
View File
@@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
@@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
@@ -146,7 +146,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
"px-2 py-1.5 text-sm font-medium",
inset && "pl-8",
className
)}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-glass-border bg-input px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+1 -1
View File
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
"z-50 w-72 rounded-md border border-glass-border bg-popover p-4 text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
+2 -2
View File
@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -103,7 +103,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
))
+3 -3
View File
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"fixed z-50 gap-4 bg-popover p-6 shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] border-glass-border transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
@@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
className={cn("text-lg font-medium text-foreground", className)}
{...props}
/>
))
+1 -1
View File
@@ -17,7 +17,7 @@ const TabsList = React.forwardRef<
<AnimateTabsList
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
'inline-flex h-9 items-center justify-center rounded-lg bg-glass border border-glass-border p-1 text-muted-foreground',
className
)}
{...props}
+1 -1
View File
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground',
'z-50 overflow-hidden rounded-md border border-glass-border bg-popover px-3 py-1.5 text-xs text-popover-foreground backdrop-blur-[10px] backdrop-saturate-[1.15]',
'origin-[--radix-tooltip-content-transform-origin]',
'animate-in fade-in-0 zoom-in-95 duration-150',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
+210 -74
View File
@@ -8,24 +8,24 @@
LIGHT THEME
───────────────────────────────────────────────────────────── */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0 0 0);
--background: oklch(0.97 0 0);
--foreground: oklch(0.15 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.9900 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--card-foreground: oklch(0.15 0 0);
--popover: oklch(1 0 0 / 0.92);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.15 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9400 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.4400 0 0);
--accent: oklch(0.9400 0 0);
--accent-foreground: oklch(0 0 0);
--secondary: oklch(0 0 0 / 0.05);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0 0 0 / 0.04);
--muted-foreground: oklch(0.50 0 0);
--accent: oklch(0 0 0 / 0.06);
--accent-foreground: oklch(0.15 0 0);
--destructive: oklch(0.6300 0.1900 23.0300);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9200 0 0);
--input: oklch(0.9400 0 0);
--border: oklch(0 0 0 / 0.10);
--input: oklch(0.97 0 0);
--ring: oklch(0.50 0.14 200);
/* Brand - technical cyan, the signature accent */
@@ -33,6 +33,22 @@
--brand-foreground: oklch(1 0 0);
--brand-muted: oklch(0.50 0.14 200 / 0.12);
/* Glass — frosted surface system */
--glass: oklch(1 0 0);
--glass-border: oklch(0 0 0 / 0.10);
--glass-highlight: oklch(0 0 0 / 0.06);
/* Semantic status colors */
--success: oklch(0.65 0.17 155);
--success-foreground: oklch(1 0 0);
--success-muted: oklch(0.65 0.17 155 / 0.12);
--warning: oklch(0.75 0.16 60);
--warning-foreground: oklch(0.20 0 0);
--warning-muted: oklch(0.75 0.16 60 / 0.12);
--info: oklch(0.62 0.14 250);
--info-foreground: oklch(1 0 0);
--info-muted: oklch(0.62 0.14 250 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5500 0.2200 264.5300);
@@ -41,15 +57,30 @@
--chart-5: oklch(0.5600 0 0);
/* Sidebar */
--sidebar: oklch(0.9900 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar: oklch(0.98 0 0 / 0.80);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.15 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9400 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.9400 0 0);
--sidebar-accent: oklch(0 0 0 / 0.06);
--sidebar-accent-foreground: oklch(0.15 0 0);
--sidebar-border: oklch(0 0 0 / 0.10);
--sidebar-ring: oklch(0.50 0.14 200);
/* Stat hierarchy — text brightness tiers */
--stat-value: oklch(0.15 0 0);
--stat-title: oklch(0.40 0 0);
--stat-subtitle: oklch(0.50 0 0);
--stat-icon: oklch(0.60 0 0);
/* Card borders — directional lighting */
--card-border: oklch(0 0 0 / 0.08);
--card-border-top: oklch(0 0 0 / 0.12);
--card-border-hover: oklch(0 0 0 / 0.15);
/* Chart grid & axis */
--chart-grid: oklch(0 0 0 / 0.06);
--chart-tick: oklch(0 0 0 / 0.45);
/* Typography */
--font-sans: 'Geist', sans-serif;
--font-serif: Georgia, serif;
@@ -58,21 +89,25 @@
/* Shape */
--radius: 0.5rem;
/* Shadows */
/* Shadows — subtle, glass depth comes from blur not shadow */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.18;
--shadow-opacity: 0.10;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.08);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.08), 0px 1px 2px -1px hsl(0 0% 0% / 0.08);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.25);
/* Material simulation — inherent depth */
--card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.05);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.04);
/* Motion timing */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -90,47 +125,78 @@
DARK THEME
───────────────────────────────────────────────────────────── */
.dark {
--background: oklch(0.1400 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1600 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.1800 0 0);
--popover-foreground: oklch(1 0 0);
--background: oklch(0.08 0 0);
--foreground: oklch(0.92 0 0);
--card: oklch(0.12 0 0);
--card-foreground: oklch(0.92 0 0);
--popover: oklch(0.14 0 0 / 0.82);
--popover-foreground: oklch(0.92 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2500 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.2300 0 0);
--muted-foreground: oklch(0.7200 0 0);
--accent: oklch(0.3200 0 0);
--accent-foreground: oklch(1 0 0);
--primary-foreground: oklch(0.10 0 0);
--secondary: oklch(0.12 0 0);
--secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.12 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(1 0 0 / 0.06);
--accent-foreground: oklch(0.92 0 0);
--destructive: oklch(0.6900 0.2000 23.9100);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.2600 0 0);
--input: oklch(0.3200 0 0);
--ring: oklch(0.72 0.14 200);
--destructive-foreground: oklch(0.92 0 0);
--border: oklch(1 0 0 / 0.06);
--input: oklch(0.12 0 0);
--ring: oklch(0.75 0.08 192);
/* Brand - bright technical cyan on dark backgrounds */
--brand: oklch(0.72 0.14 200);
/* Brand - desaturated teal accent */
--brand: oklch(0.75 0.08 192);
--brand-foreground: oklch(0.05 0 0);
--brand-muted: oklch(0.72 0.14 200 / 0.12);
--brand-muted: oklch(0.75 0.08 192 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5800 0.2100 260.8400);
--chart-3: oklch(0.5600 0 0);
--chart-4: oklch(0.4400 0 0);
--chart-5: oklch(0.9200 0 0);
/* Glass — solid surface system (no blur on static surfaces) */
--glass: oklch(0.12 0 0);
--glass-border: oklch(1 0 0 / 0.06);
--glass-highlight: oklch(1 0 0 / 0.06);
/* Semantic status colors */
--success: oklch(0.72 0.17 155);
--success-foreground: oklch(0.10 0 0);
--success-muted: oklch(0.72 0.17 155 / 0.15);
--warning: oklch(0.80 0.16 60);
--warning-foreground: oklch(0.10 0 0);
--warning-muted: oklch(0.80 0.16 60 / 0.15);
--info: oklch(0.70 0.14 250);
--info-foreground: oklch(0.10 0 0);
--info-muted: oklch(0.70 0.14 250 / 0.15);
/* Charts — monochrome blue/teal */
--chart-1: oklch(0.72 0.08 220);
--chart-2: oklch(0.65 0.08 220);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
/* Sidebar */
--sidebar: oklch(0.1800 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar: oklch(0.08 0 0 / 0.80);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.3200 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3200 0 0);
--sidebar-ring: oklch(0.72 0.14 200);
--sidebar-primary-foreground: oklch(0.10 0 0);
--sidebar-accent: oklch(1 0 0 / 0.07);
--sidebar-accent-foreground: oklch(0.92 0 0);
--sidebar-border: oklch(1 0 0 / 0.06);
--sidebar-ring: oklch(0.75 0.08 192);
/* Stat hierarchy — text brightness tiers */
--stat-value: oklch(0.93 0 0);
--stat-title: oklch(0.55 0 0);
--stat-subtitle: oklch(0.45 0 0);
--stat-icon: oklch(0.35 0 0);
/* Card borders — directional lighting */
--card-border: oklch(1 0 0 / 0.07);
--card-border-top: oklch(1 0 0 / 0.14);
--card-border-hover: oklch(1 0 0 / 0.14);
/* Chart grid & axis */
--chart-grid: oklch(1 0 0 / 0.05);
--chart-tick: oklch(1 0 0 / 0.40);
/* Typography */
--font-sans: 'Geist', sans-serif;
@@ -140,21 +206,25 @@
/* Shape */
--radius: 0.5rem;
/* Shadows - deeper on dark backgrounds */
/* Material simulation — inherent depth */
--card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.05);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.06);
/* Shadows — near-zero, depth comes from surface tone */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.35;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 2px 4px -1px hsl(0 0% 0% / 0.35);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 4px 6px -1px hsl(0 0% 0% / 0.35);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 8px 10px -1px hsl(0 0% 0% / 0.35);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.65);
--shadow-2xs: 0 0 0 0 transparent;
--shadow-xs: 0 0 0 0 transparent;
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.15);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.15);
--shadow-md: 0px 2px 4px 0px hsl(0 0% 0% / 0.15);
--shadow-lg: 0px 4px 6px -1px hsl(0 0% 0% / 0.15);
--shadow-xl: 0px 8px 10px -1px hsl(0 0% 0% / 0.20);
--shadow-2xl: 0px 12px 20px -2px hsl(0 0% 0% / 0.30);
}
/* ─────────────────────────────────────────────────────────────
@@ -197,6 +267,32 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-glass: var(--glass);
--color-glass-border: var(--glass-border);
--color-glass-highlight: var(--glass-highlight);
--color-stat-value: var(--stat-value);
--color-stat-title: var(--stat-title);
--color-stat-subtitle: var(--stat-subtitle);
--color-stat-icon: var(--stat-icon);
--color-card-border: var(--card-border);
--color-card-border-top: var(--card-border-top);
--color-card-border-hover: var(--card-border-hover);
--color-chart-grid: var(--chart-grid);
--color-chart-tick: var(--chart-tick);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-muted: var(--success-muted);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-muted: var(--warning-muted);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-muted: var(--info-muted);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
@@ -214,6 +310,8 @@
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--shadow-card-bevel: var(--card-bevel);
--shadow-btn-glow: var(--button-inner-glow);
}
/* ─────────────────────────────────────────────────────────────
@@ -229,10 +327,28 @@
}
body {
@apply bg-background text-foreground;
@apply text-foreground;
background:
radial-gradient(
circle at 50% 0%,
oklch(0.95 0.02 60 / 0.3),
transparent
),
var(--background);
}
}
/* Dark mode: teal-tinted ambient glow */
.dark body {
background:
radial-gradient(
circle at 50% 0%,
oklch(0.25 0.05 250 / 0.15),
transparent
),
oklch(0.08 0 0);
}
/* ─────────────────────────────────────────────────────────────
TYPOGRAPHY & RENDERING
───────────────────────────────────────────────────────────── */
@@ -280,6 +396,26 @@ body {
background: oklch(0.50 0 0 / 0.8);
}
/* ─────────────────────────────────────────────────────────────
GLASS UTILITIES
───────────────────────────────────────────────────────────── */
.glass {
background: var(--glass);
border: 1px solid var(--glass-border);
}
.glass-strong {
background: var(--glass);
border: 1px solid var(--glass-border);
}
.glass-float {
background: oklch(0.12 0 0 / 0.82);
backdrop-filter: blur(10px) saturate(1.15);
-webkit-backdrop-filter: blur(10px) saturate(1.15);
border: 1px solid oklch(1 0 0 / 0.07);
}
/* ─────────────────────────────────────────────────────────────
ANIMATION KEYFRAMES
───────────────────────────────────────────────────────────── */
+7
View File
@@ -0,0 +1,7 @@
import type { Transition } from 'motion/react';
export const springs = {
snappy: { type: 'spring', stiffness: 350, damping: 30 } as Transition,
gentle: { type: 'spring', stiffness: 200, damping: 25 } as Transition,
slow: { type: 'spring', stiffness: 150, damping: 25 } as Transition,
} as const;