diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index deead18b..b2271cf4 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -127,7 +127,7 @@ export function ApiTokensSection() {
-

+

API Tokens

@@ -185,8 +185,8 @@ export function ApiTokensSection() { {/* Token reveal (shown once after creation) */} {newToken && ( -

-
+
+
Token created - copy it now

This token will not be shown again. Store it securely.

diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index 08aafec4..d615c26f 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -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'; diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index f72fcd27..5ea5384d 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -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 Bash: {containerName} {isConnected && ( - + Connected )} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 70af9dc8..78ea6744 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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 (
{/* Left Sidebar (Stacks) */} -
+
{/* Branding Header */}
Sencho Logo -

Sencho

+

Sencho

@@ -1113,7 +1113,7 @@ export default function EditorLayout() { {nodes.map(node => (
-
{node.name} @@ -1129,7 +1129,7 @@ export default function EditorLayout() { {can('stack:create') &&
- @@ -1164,7 +1164,7 @@ export default function EditorLayout() { className="h-9" />
-

STACKS

+

STACKS

@@ -1182,19 +1182,21 @@ export default function EditorLayout() { 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' : ''}`} >
-
- {getDisplayName(file)} + > + {stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'} + + {getDisplayName(file)} {stackUpdates[file] && ( )} @@ -1312,13 +1314,13 @@ export default function EditorLayout() { {/* LEFT ZONE: Node Context Pill */}
{activeNode?.type === 'remote' ? ( -
- +
+ {activeNode.name}
) : (
- + {activeNode?.name ?? 'Local'}
)} @@ -1327,24 +1329,26 @@ export default function EditorLayout() { {/* CENTER ZONE: Navigation Group (hidden on mobile) */}
-
+
{navItems.map(({ value, label, icon: Icon }) => ( @@ -1373,7 +1377,7 @@ export default function EditorLayout() {
-

Notifications

+

Notifications

{notifications.filter(n => !n.is_read).length > 0 && ( ) : ( )} {isPro && backupInfo.exists && ( @@ -1516,7 +1520,7 @@ export default function EditorLayout() { @@ -1531,15 +1535,15 @@ export default function EditorLayout() {
@@ -1549,7 +1553,7 @@ export default function EditorLayout() { {/* Containers List */}
-

CONTAINERS

+

CONTAINERS

{safeContainers.length === 0 ? (
No containers running for this stack.
) : ( @@ -1584,7 +1588,7 @@ export default function EditorLayout() {
-

Container Status

+

Container Status

{container?.Status || 'No status details available'}

@@ -1664,8 +1668,8 @@ export default function EditorLayout() { {/* Terminal Section */} -
-

Terminal

+
+

Terminal

@@ -1679,19 +1683,15 @@ export default function EditorLayout() {
setActiveTab(value as 'compose' | 'env')}> - - - {activeTab === 'compose' && ( - - )} - compose.yaml - - - {activeTab === 'env' && ( - - )} - .env - + + + + compose.yaml + + + .env + + @@ -1738,7 +1738,7 @@ export default function EditorLayout() {
{activeTab === 'env' && ( -
+
Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. @@ -1762,6 +1762,7 @@ export default function EditorLayout() { }} options={{ minimap: { enabled: false }, + fontFamily: "'Geist Mono', monospace", fontSize: 14, padding: { top: 10 }, scrollBeyondLastLine: false, diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index d0f2cf97..fb96ce66 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -290,10 +290,10 @@ export default function FleetSnapshots() { const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes); if (skipped.length === 0) return null; return ( -
+
- - + + Some nodes were unreachable during snapshot creation:
@@ -519,7 +519,7 @@ export default function FleetSnapshots() { {skipped.length > 0 ? ( diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 417e5e83..4266911a 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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 ( -
+
- - {label} + + {label}
-
{value}
- {sub &&

{sub}

} +
{value}
+ {sub &&

{sub}

}
); } @@ -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 (
@@ -297,11 +298,11 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
-
- +
+
-

{node.name}

+

{node.name}

{isOnline ? ( @@ -327,15 +328,15 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: {isOnline && node.stats && (
-
{node.stats.active}
+
{node.stats.active}
Running
-
{node.stats.exited}
+
{node.stats.exited}
Stopped
-
{node.stacks?.length ?? '-'}
+
{node.stacks?.length ?? '-'}
Stacks
@@ -351,7 +352,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: {node.systemStats.cpu.usage}%
- 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-amber-500' : 'bg-emerald-500'} /> + 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
@@ -360,7 +361,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}
- 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-amber-500' : 'bg-blue-500'} /> + 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
{node.systemStats.disk && (
@@ -370,7 +371,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}
- 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-amber-500' : 'bg-violet-500'} /> + 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} />
)}
@@ -552,7 +553,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {/* Header */}
-

Fleet Overview

+

Fleet Overview

{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}

@@ -571,12 +572,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - Overview - {isPro && ( - - Snapshots - - )} + + + Overview + + {isPro && ( + + + Snapshots + + + )} + diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index a4d16d62..f99b353e 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -262,7 +262,7 @@ export function GlobalObservabilityView() { {devMode && ( -
+
● LIVE
)} @@ -286,7 +286,7 @@ export function GlobalObservabilityView() {
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] - {log.level}: + {log.level}: {log.message}
))} diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index fc1d4927..ea09d1ef 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -71,6 +71,12 @@ export default function HomeDashboard() { const [systemStats, setSystemStats] = useState(null); const [metrics, setMetrics] = useState([]); + 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 (
{/* Container Stats Row */} -
- +
+ - Active Containers - + Active Containers + -
{stats.active}
-

+

{stats.active}
+

{stats.managed} managed · {stats.unmanaged} external

- + - Exited Containers - + Exited Containers + -
{stats.exited}
-

Stopped or crashed

+
{stats.exited}
+

Stopped or crashed

- + - Docker Network - + Docker Network + -
+
{systemStats?.network ? `${formatBytes(systemStats.network.rxSec)}/s ↓` : '...'}
-

+

{systemStats?.network ? `${formatBytes(systemStats.network.txSec)}/s ↑` : 'Loading...'} @@ -260,32 +266,32 @@ export default function HomeDashboard() {

{/* Host System Stats Row */} -
- +
+ - Host CPU - + Host CPU + -
+
{systemStats ? `${systemStats.cpu.usage}%` : '...'}
-

+

{systemStats ? `${systemStats.cpu.cores} cores` : 'Loading...'}

- + - Host RAM - + Host RAM + -
+
{systemStats ? `${systemStats.memory.usagePercent}%` : '...'}
-

+

{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : 'Loading...'} @@ -293,16 +299,16 @@ export default function HomeDashboard() { - + - Host Disk - + Host Disk + -

+
{systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'}
-

+

{systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : 'Loading...'} @@ -313,10 +319,10 @@ export default function HomeDashboard() { {/* Historical Charts */}

- + - - + + Normalized CPU Usage Total CPU percentage over total host cores. @@ -325,9 +331,9 @@ export default function HomeDashboard() { {chartData.length > 0 ? ( - - - `${Number(val).toFixed(0)}%`} domain={[0, 100]} /> + + + `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> } /> @@ -340,10 +346,10 @@ export default function HomeDashboard() { - + - - + + Normalized RAM Usage Total RAM allocation in GB. @@ -352,9 +358,9 @@ export default function HomeDashboard() { {chartData.length > 0 ? ( - - - `${Number(val).toFixed(1)} GB`} /> + + + `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> } /> @@ -369,7 +375,7 @@ export default function HomeDashboard() {
{/* Docker Run Converter */} - + Convert Docker Run to Compose

diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index 86c14e7c..34a69ed3 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -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) { )} {isConnected && ( - + Connected )} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index fd5f43f0..592d69ec 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -61,7 +61,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi - {containerName} {isConnected ? (connected) : } + {containerName} {isConnected ? (connected) : } diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index b82b5ab0..8b94091f 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -107,7 +107,7 @@ export function Login({ draggable={false} />

-

Sencho

+

Sencho

Docker Compose Management

diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 87e864a2..fe366c3d 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -311,7 +311,7 @@ export function NodeManager() { {/* Header */}
-

+

Nodes

@@ -380,7 +380,7 @@ export function NodeManager() {
{generatedToken}
)} @@ -490,8 +490,8 @@ export function NodeManager() { {/* Connection Test Result */} {testResult && (
-

- +

+ Connection Details - {nodes.find(n => n.id === testResult.nodeId)?.name}

diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 30c30bb7..7b1c1955 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -209,7 +209,7 @@ export function RegistriesSection() {
-

+

Private Registries

@@ -365,7 +365,7 @@ export function RegistriesSection() { {reg.username} {reg.has_secret ? ( - <> Secret stored + <> Secret stored ) : ( <> No secret )} diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index efc68b81..94f327dc 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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 ( - - + + {managedBy} ); } if (status === 'unmanaged') { return ( - - + + External ); @@ -256,7 +257,7 @@ function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: Pru {icon} - {label} + {label} Sencho only {target !== 'containers' && ( @@ -451,7 +452,7 @@ export default function ResourcesView() { {/* Header */}

-

Resources Hub

+

Resources Hub

{activeNode?.type === 'remote' && ( - {activeNode.name} )} @@ -519,7 +520,7 @@ export default function ResourcesView() { target="networks" icon={} 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={} 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" > -
- - {(['images', 'volumes', 'networks'] as const).map(tab => ( - - {tab} - - ))} - - Unmanaged - {totalOrphansCount > 0 && ( - - {totalOrphansCount} - - )} - +
+ + + {(['images', 'volumes', 'networks'] as const).map(tab => ( + + + {tab} + + + ))} + + + Unmanaged + {totalOrphansCount > 0 && ( + + {totalOrphansCount} + + )} + + +
@@ -726,7 +726,7 @@ export default function ResourcesView() { {/* Unmanaged Containers */} -
+
-
- +
+

No unmanaged containers

All running containers are managed by Sencho.

@@ -765,9 +765,9 @@ export default function ResourcesView() { style={{ animationDelay: `${gi * 60}ms` }} > {/* Project header */} -
- - External Project: +
+ + External Project: {project} {containers.length} container{containers.length !== 1 ? 's' : ''}
@@ -785,7 +785,7 @@ export default function ResourcesView() { />
- + {container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} - This will prune all unused {confirmPrune?.target} from the Docker daemon - - including those from external projects not managed by Sencho. This cannot be undone. + This will prune all unused {confirmPrune?.target} from the Docker daemon - + including those from external projects not managed by Sencho. This cannot be undone. ) : ( @@ -832,7 +832,7 @@ export default function ResourcesView() { Prune Sencho-Managed {confirmPrune?.target} Only unused {confirmPrune?.target} belonging to your Sencho stacks will be removed. - External Docker resources are not affected. + External Docker resources are not affected. )} @@ -856,7 +856,7 @@ export default function ResourcesView() { Delete {confirmDelete?.type.slice(0, -1)} - Permanently delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. + Permanently delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index fe137dd0..11ed457c 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -127,7 +127,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
{label} {initialConfig?.enabled && ( - + Active )} @@ -298,7 +298,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: { {testResult && ( testResult.success - ? + ? : )}
@@ -333,7 +333,7 @@ export function SSOSection() {
-

+

SSO Authentication

diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 1bdf5307..1d364d97 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -340,7 +340,7 @@ export default function ScheduledOperationsView() { {task.last_status === 'success' ? ( - Success + Success ) : task.last_status === 'failure' ? ( Failed ) : ( @@ -541,7 +541,7 @@ export default function ScheduledOperationsView() { {run.status === 'success' ? ( - Success + Success ) : run.status === 'failure' ? ( Failed ) : ( diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 03be48d7..27b680b3 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useRef } from 'react'; -import { motion } from 'motion/react'; import { Dialog, DialogContent, @@ -7,827 +6,47 @@ import { DialogDescription, } from '@/components/ui/dialog'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Input } from '@/components/ui/input'; -import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Slider } from '@/components/ui/slider'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, Check, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug, Zap, Compass, ShipWheel } from 'lucide-react'; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Shield, Activity, Bell, Code, Server, Package, + Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, +} from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; -import { useAuth, type UserRole } from '@/context/AuthContext'; +import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; -import { TierBadge } from './TierBadge'; -import { ProGate } from './ProGate'; import { SSOSection } from './SSOSection'; import { ApiTokensSection } from './ApiTokensSection'; import { RegistriesSection } from './RegistriesSection'; - -interface Agent { - type: 'discord' | 'slack' | 'webhook'; - url: string; - enabled: boolean; -} - -// Keys that the settings PATCH endpoint accepts -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; -} - -type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'registries' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; - -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; -} +import { + AccountSection, + LicenseSection, + UsersSection, + SystemSection, + NotificationsSection, + WebhooksSection, + DeveloperSection, + AppStoreSection, + SupportSection, + AboutSection, + DEFAULT_SETTINGS, +} from './settings'; +import type { PatchableSettings, SectionId } from './settings'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; } -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', -}; - -function WebhooksSection({ isPro }: { isPro: boolean }) { - const [webhooks, setWebhooks] = useState([]); - 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(null); - const [history, setHistory] = useState>({}); - const [loadingHistory, setLoadingHistory] = useState(null); - - // Form state - const [formName, setFormName] = useState(''); - const [formStack, setFormStack] = useState(''); - const [formAction, setFormAction] = useState('deploy'); - const [stacks, setStacks] = useState([]); - - 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 ( -
-
-

Webhooks

-

Trigger stack actions from CI/CD pipelines via HTTP.

-
- -
-
-
-
- -
- ); - } - - return ( -
-
-
-

Webhooks

-

Trigger stack actions from CI/CD pipelines via HTTP.

-
- -
- - {/* Create Form */} - {showForm && ( -
-
- - setFormName(e.target.value)} /> -
-
- - -
-
- - -
-
- - -
-
- )} - - {/* Secret reveal (shown once after creation) */} - {newSecret && ( -
-
- Webhook created - copy your secret now -
-

This secret will not be shown again. Store it securely.

-
- {newSecret.secret} - -
- -
- )} - - {/* Loading state */} - {loading && ( -
- - -
- )} - - {/* Empty state */} - {!loading && webhooks.length === 0 && !showForm && ( -
- -

No webhooks configured yet.

-

Create one to trigger stack actions from CI/CD.

-
- )} - - {/* Webhook list */} - {!loading && webhooks.map(wh => { - const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`; - const isExpanded = expandedHistory === wh.id; - return ( -
-
-
-
- - {wh.name} - {wh.action} - {wh.stack_name} -
-
- handleToggle(wh.id!, c)} /> - -
-
- - {/* Trigger URL */} -
- -
- {triggerUrl} - -
-
- - {/* Secret (masked) */} -
- Secret: - {wh.secret} -
- - {/* History toggle */} - -
- - {/* Execution history */} - {isExpanded && ( -
- {loadingHistory === wh.id ? ( - - ) : (history[wh.id!] ?? []).length === 0 ? ( -

No executions yet.

- ) : ( -
- {(history[wh.id!] ?? []).map(ex => ( -
- {ex.status === 'success' - ? - : } - {ex.action} - - {new Date(ex.executed_at).toLocaleString()} - - {ex.duration_ms !== null && ( - {(ex.duration_ms / 1000).toFixed(1)}s - )} - {ex.error && ( - {ex.error} - )} -
- ))} -
- )} -
- )} -
- ); - })} -
- ); -} - -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; -} - -function UsersSection() { - const { user: currentUser } = useAuth(); - const { isPro, license } = useLicense(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [editingUser, setEditingUser] = useState(null); - const [saving, setSaving] = useState(false); - - // Form state - const [formUsername, setFormUsername] = useState(''); - const [formPassword, setFormPassword] = useState(''); - const [formConfirmPassword, setFormConfirmPassword] = useState(''); - const [formRole, setFormRole] = useState('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 = { 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([]); - const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); - const [scopeResourceId, setScopeResourceId] = useState(''); - const [scopeRole, setScopeRole] = useState('deployer'); - const [availableStacks, setAvailableStacks] = useState([]); - 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 ( - -
-
-
-

User Management

-

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

-
- {!showForm && ( - - )} -
- - {/* Add/Edit Form */} - {showForm && ( -
-

{editingUser ? 'Edit User' : 'New User'}

-
-
- - setFormUsername(e.target.value)} - placeholder="username" - /> -
-
- - -
-
-
-
- - setFormPassword(e.target.value)} - placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} - /> -
-
- - setFormConfirmPassword(e.target.value)} - placeholder="Confirm password" - /> -
-
-
- - -
- - {/* Scoped Permissions (Admiral, editing only) */} - {editingUser && isPro && license?.variant === 'team' && ( -
-

Scoped Permissions

-

- Grant additional permissions on specific stacks or nodes. These supplement the user's global role. -

- - {roleAssignments.length > 0 && ( -
- {roleAssignments.map((a) => ( -
- - {a.role} - on {a.resource_type}: {a.resource_id} - - -
- ))} -
- )} - -
-
- - -
-
- - -
-
- - -
- -
-
- )} -
- )} - - {/* Users Table */} - {loading ? ( -
- - -
- ) : users.length === 0 ? ( -
No users found.
- ) : ( -
- - - - - - - - - - - {users.map((u) => { - const isSelf = u.username === currentUser?.username; - return ( - - - - - - - ); - })} - -
UsernameRoleCreatedActions
- {u.username} - {isSelf && (you)} - - - {u.role} - - - {new Date(u.created_at).toLocaleDateString()} - -
- - - - - - - - Delete user "{u.username}"? - - This action cannot be undone. The user will lose access immediately. - - - - Cancel - handleDelete(u.id)}>Delete - - - -
-
-
- )} -
-
- ); -} - export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); - const { license, isPro, activate, deactivate } = useLicense(); + const { license, isPro } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); - const [licenseKeyInput, setLicenseKeyInput] = useState(''); - const [isActivating, setIsActivating] = useState(false); - const [isDeactivating, setIsDeactivating] = useState(false); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { @@ -836,35 +55,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps - // Notification tab state (controlled for sliding indicator) - const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord'); - // Auth State const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); + const [isSavingPassword, setIsSavingPassword] = useState(false); - // Notification agents state - const [agents, setAgents] = useState>({ - discord: { type: 'discord', url: '', enabled: false }, - slack: { type: 'slack', url: '', enabled: false }, - webhook: { type: 'webhook', url: '', enabled: false }, - }); - - // Settings state - all user-configurable keys (no auth keys) + // Settings state const [settings, setSettings] = useState({ ...DEFAULT_SETTINGS }); - - // Track server state to detect unsaved changes without causing re-renders const serverSettingsRef = useRef({ ...DEFAULT_SETTINGS }); - - // Per-operation loading states const [isSettingsLoading, setIsSettingsLoading] = useState(false); const [isSavingSystem, setIsSavingSystem] = useState(false); const [isSavingDeveloper, setIsSavingDeveloper] = useState(false); - const [isSavingPassword, setIsSavingPassword] = useState(false); - const [isSavingRegistry, setIsSavingRegistry] = useState(false); - const [isSavingAgent, setIsSavingAgent] = useState>({}); - const [isTestingAgent, setIsTestingAgent] = useState>({}); - // Unsaved changes indicators per section (compared against server ref) + // Unsaved changes indicators const hasSystemChanges = settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit || settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit || @@ -880,51 +82,26 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days; useEffect(() => { - if (isOpen) { - fetchAgents(); - fetchSettings(); - } + if (isOpen) fetchSettings(); }, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps - 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); - } - }; - const fetchSettings = async () => { setIsSettingsLoading(true); try { - // Fetch per-node settings from the active node (system limits etc.) const nodeRes = await apiFetch('/settings'); - // Always fetch developer/UI preferences from local - these control - // this Sencho instance's behaviour and must never be proxied to remote const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes; - const nodeData: Record = nodeRes.ok ? await nodeRes.json() : {}; const localData: Record = (isRemote && localRes.ok) ? await localRes.json() : nodeData; const safe: PatchableSettings = { - // Per-node: read from active node host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, template_registry_url: nodeData.template_registry_url ?? '', - // Local-only: always read from local node global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh, developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, @@ -979,7 +156,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { }; const saveDeveloperSettings = async () => { - // Developer/UI preferences are local-only - never proxy to remote node const ok = await patchSettings({ developer_mode: settings.developer_mode, global_logs_refresh: settings.global_logs_refresh, @@ -990,79 +166,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { if (ok) toast.success('Developer settings saved.'); }; - 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; - } - serverSettingsRef.current = { ...serverSettingsRef.current, 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); - } - }; - - 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 handlePasswordChange = async () => { if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) { toast.error('All fields are required'); @@ -1080,7 +183,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { try { const res = await apiFetch('/auth/password', { method: 'PUT', - body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }) + body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }), }); if (res.ok) { toast.success('Password updated successfully'); @@ -1096,49 +199,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } }; - const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => ( -
-
- - handleAgentChange(type, 'enabled', c)} - /> -
-
- - handleAgentChange(type, 'url', e.target.value)} - /> -
-
- - -
-
- ); + const handleRegistrySaved = (key: keyof PatchableSettings, value: string) => { + serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value }; + }; - const SettingsSkeleton = () => ( -
- - -
- - - -
-
- ); - - const NavButton = ({ section, icon, label, showDot }: { section: SectionId; icon: React.ReactNode; label: string; showDot?: boolean }) => ( + // --- Nav items --- + const NavButton = ({ section, icon, label, showDot, locked }: { + section: SectionId; + icon: React.ReactNode; + label: string; + showDot?: boolean; + locked?: boolean; + }) => ( ); + // --- Section rendering --- + const renderSection = () => { + switch (activeSection) { + case 'account': + return ( + + ); + case 'license': + return ; + case 'users': + return ; + case 'sso': + return ; + case 'api-tokens': + return ; + case 'registries': + return ; + case 'system': + return ( + + ); + case 'notifications': + return ; + case 'webhooks': + return ; + case 'developer': + return ( + + ); + case 'nodes': + return ; + case 'appstore': + return ( + + ); + case 'support': + return ; + case 'about': + return ; + } + }; + + const isTeamPro = isPro && license?.variant === 'team'; + return ( !open && onClose()}> - + Settings Hub Configure Sencho settings + {/* Sidebar */} -
-
Settings Hub
+
+
Settings Hub
{isRemote ? (
{activeNode!.name}
) : (
)} @@ -1213,773 +373,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {/* Main Content Area */}
- - {activeSection === 'account' && ( -
-
-

Account & Security

-

Manage your credentials and authentication.

-
-
-
- - setAuthData(prev => ({ ...prev, oldPassword: e.target.value }))} - /> -
-
- - setAuthData(prev => ({ ...prev, newPassword: e.target.value }))} - /> -
-
- - setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))} - /> -
- -
-
- )} - - {activeSection === 'license' && ( -
-
-

License

-

Manage your Sencho Pro license.

-
- - {/* Current Tier Display */} -
-
-
- {license?.tier === 'pro' ? ( - - ) : ( - - )} - - {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'} - -
- -
- - {license?.status === 'trial' && license.trialDaysRemaining !== null && ( -
- - Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining -
- )} - - {license?.status === 'active' && ( -
- {license.customerName && ( -
- Customer - {license.customerName} -
- )} - {license.productName && ( -
- Plan - {license.productName} -
- )} - {license.maskedKey && ( -
- License Key - {license.maskedKey} -
- )} - {license.validUntil && ( -
- Renews - {new Date(license.validUntil).toLocaleDateString()} -
- )} -
- )} - - {license?.status === 'expired' && ( -
- - Your Pro license has expired. Renew to restore Pro features. -
- )} - - {license?.status === 'disabled' && ( -
- - Your license has been disabled. Contact support for assistance. -
- )} -
- - {/* Manage Subscription (active Pro) */} - {license?.status === 'active' && ( -
- {license.portalUrl && ( - - )} -
-

- Deactivating will revert to Community features. -

- -
-
- )} - - {/* Upgrade Cards - Community: show both, Skipper: show Admiral only, Admiral: none */} - {(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && ( -
- -
- {/* Skipper Card - only for Community users */} - {license?.tier !== 'pro' && ( -
-
- - Skipper - Popular -
-

Professional tools for solo operators.

-
    - {['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => ( -
  • - - {f} -
  • - ))} -
- -
- )} - - {/* Admiral Card */} -
-
- - Admiral -
-

For teams managing shared infrastructure.

-
    - {[ - ...(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) => ( -
  • - - {f} -
  • - ))} -
- -
-
-
- )} - - {/* License key activation - show when not active */} - {license?.status !== 'active' && ( -
- -
- setLicenseKeyInput(e.target.value)} - className="font-mono" - /> - -
-
- )} -
- )} - - {activeSection === 'system' && ( -
-
-
-

System Limits & Watchdog

-

Configure alert thresholds and crash detection.

-
- {isRemote && ( - - - Configuring: {activeNode!.name} - - )} -
- - {isSettingsLoading ? : ( - <> -
-
-
- - {settings.host_cpu_limit}% -
- handleSettingChange('host_cpu_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_ram_limit}% -
- handleSettingChange('host_ram_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_disk_limit}% -
- handleSettingChange('host_disk_limit', v[0].toString())} - /> -
- -
- -
- handleSettingChange('docker_janitor_gb', e.target.value)} - className="max-w-[150px]" - /> - GB reclaimable -
-

Alert when unused Docker data exceeds this size.

-
- -
-
- -

Watch all containers for unexpected exits

-
- handleSettingChange('global_crash', c ? '1' : '0')} - /> -
-
- -
- -
- - )} -
- )} - - {activeSection === 'notifications' && ( -
-
-
-

Notifications & Alerts

-

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

-
- {isRemote && ( - - - - - - Remote - - - - These channels are saved on the remote Sencho instance and used when it dispatches alerts. - - - - )} -
- setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> - - - {notifTab === 'discord' && ( - - )} - Discord - - - {notifTab === 'slack' && ( - - )} - Slack - - - {notifTab === 'webhook' && ( - - )} - Webhook - - - {renderAgentTab('discord', 'Discord')} - {renderAgentTab('slack', 'Slack')} - {renderAgentTab('webhook', 'Custom Webhook')} - -
- )} - - {activeSection === 'webhooks' && ( - - )} - - {activeSection === 'users' && ( - - )} - - {activeSection === 'sso' && ( - - )} - - {activeSection === 'api-tokens' && ( - - )} - - {activeSection === 'registries' && ( - - )} - - {activeSection === 'developer' && ( -
-
-
-

Developer

-

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

-
- {isRemote && ( - - - - - - Always Local - - - - These settings control this Sencho instance's UI behaviour and are never synced to remote nodes. - - - - )} -
- - {isSettingsLoading ? : ( - <> -
-
-
- -

Enable Real-Time Metrics & Extended Logs

-
- handleSettingChange('developer_mode', c ? '1' : '0')} - /> -
- -
- - - {settings.developer_mode === '1' && ( -

SSE streaming is active - polling rate is overridden.

- )} -
-
- - {/* Data Retention (Observability) */} -
-
- - Data Retention -
-
-
-
- -

How long to keep per-container CPU/RAM/network history.

-
-
- handleSettingChange('metrics_retention_hours', e.target.value)} - className="w-20" - /> - hrs -
-
- -
-
- -

How long to keep alert and notification history.

-
-
- handleSettingChange('log_retention_days', e.target.value)} - className="w-20" - /> - days -
-
- - {isPro && license?.variant === 'team' && ( -
-
- -

How long to keep audit trail entries.

-
-
- handleSettingChange('audit_retention_days', e.target.value)} - className="w-20" - /> - days -
-
- )} -
-
- -
- -
- - )} -
- )} - - {activeSection === 'nodes' && ( - - )} - - {activeSection === 'support' && ( -
-
-

Help & Support

-

Get help with Sencho based on your plan.

-
- - {/* Self-serve channels (all tiers) */} - - - {/* Pro support channels */} - {isPro && ( - - )} - - {/* Upsell for Community */} - {!isPro && ( -
-
- -
-

Need faster support?

-

- Upgrade to Pro for direct email support and priority issue handling. -

- -
-
-
- )} -
- )} - - {activeSection === 'about' && ( -
-
-

About Sencho

-

Version and instance information.

-
- -
-
- Version - v{__APP_VERSION__} -
-
- Tier -
-
-
- License Status - {license?.status ?? 'community'} -
- {license?.instanceId && ( -
- Instance ID - {license.instanceId.slice(0, 8)} -
- )} -
- -
-

Links

- -
-
- )} - - {activeSection === 'appstore' && ( -
-
-

App Store Registry

-

Configure the template source used by the App Store.

-
- - {isSettingsLoading ? : ( - <> -
-
- -

- LinuxServer.io - https://api.linuxserver.io/api/v1/images -

-

Used when no custom registry is set.

-
- -
-
- -

- Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. -

-
- handleSettingChange('template_registry_url', e.target.value)} - /> -

Leave empty to use the default LinuxServer.io registry.

-
-
- -
- - -
- - )} -
- )} - + {renderSection()}
diff --git a/frontend/src/components/Setup.tsx b/frontend/src/components/Setup.tsx index 1fec3159..9f29152d 100644 --- a/frontend/src/components/Setup.tsx +++ b/frontend/src/components/Setup.tsx @@ -93,7 +93,7 @@ export function Setup({ draggable={false} />
-

Sencho

+

Sencho

Docker Compose Management

diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 49e874b5..1dde86e6 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -183,22 +183,22 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP if (isRemote) { return ( -
- +
+
-

- Remote node: {activeNode?.name} +

+ Remote node: {activeNode?.name}

Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.

{!agentStatus.hasEnabled && ( -

+

No notification channels are configured on this remote node. Open Settings → Notifications to configure them.

)} {agentStatus.hasEnabled && ( -

+

Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}

)} @@ -209,10 +209,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP if (!agentStatus.hasEnabled) { return ( -
- +
+
-

No notification channels configured

+

No notification channels configured

Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '} Settings → Notifications. @@ -223,10 +223,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP } return ( -

- +
+
-

+

Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}

@@ -251,7 +251,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP {/* List Existing Alerts */}
-

Existing Rules

+

Existing Rules

{alerts.length === 0 ? (
No active alert rules for this stack. @@ -261,7 +261,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
- + {metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
@@ -287,7 +287,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP {/* Add New Alert Form */} {isAdmin &&
-

Add New Rule

+

Add New Rule

diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 031724b1..476536cb 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -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, }); diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx new file mode 100644 index 00000000..ded9b187 --- /dev/null +++ b/frontend/src/components/settings/AboutSection.tsx @@ -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 ( +
+
+

About Sencho

+

Version and instance information.

+
+ +
+
+ Version + v{__APP_VERSION__} +
+
+ Tier +
+
+
+ License Status + {license?.status ?? 'community'} +
+ {license?.instanceId && ( +
+ Instance ID + {license.instanceId.slice(0, 8)} +
+ )} +
+ +
+

Links

+ +
+
+ ); +} diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx new file mode 100644 index 00000000..e663649e --- /dev/null +++ b/frontend/src/components/settings/AccountSection.tsx @@ -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; + isSaving: boolean; +} + +export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) { + return ( +
+
+

Account & Security

+

Manage your credentials and authentication.

+
+
+
+ + onAuthDataChange({ ...authData, oldPassword: e.target.value })} + /> +
+
+ + onAuthDataChange({ ...authData, newPassword: e.target.value })} + /> +
+
+ + onAuthDataChange({ ...authData, confirmPassword: e.target.value })} + /> +
+ +
+
+ ); +} diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx new file mode 100644 index 00000000..413eca89 --- /dev/null +++ b/frontend/src/components/settings/AppStoreSection.tsx @@ -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: (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 ( +
+ + +
+ + + +
+
+ ); +} + +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 ( +
+
+

App Store Registry

+

Configure the template source used by the App Store.

+
+ + {isLoading ? : ( + <> +
+
+ +

+ LinuxServer.io - https://api.linuxserver.io/api/v1/images +

+

Used when no custom registry is set.

+
+ +
+
+ +

+ Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. +

+
+ onSettingChange('template_registry_url', e.target.value)} + /> +

Leave empty to use the default LinuxServer.io registry.

+
+
+ +
+ + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx new file mode 100644 index 00000000..a0ae3c76 --- /dev/null +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -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: (key: K, value: PatchableSettings[K]) => void; + onSave: () => Promise; + isSaving: boolean; + isLoading: boolean; + isRemote: boolean; +} + +function SettingsSkeleton() { + return ( +
+ + +
+ + + +
+
+ ); +} + +export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) { + const { isPro, license } = useLicense(); + + return ( +
+
+
+

Developer

+

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

+
+ {isRemote && ( + + + + + + Always Local + + + + These settings control this Sencho instance's UI behaviour and are never synced to remote nodes. + + + + )} +
+ + {isLoading ? : ( + <> +
+
+
+ +

Enable Real-Time Metrics & Extended Logs

+
+ onSettingChange('developer_mode', c ? '1' : '0')} + /> +
+ +
+ + + {settings.developer_mode === '1' && ( +

SSE streaming is active - polling rate is overridden.

+ )} +
+
+ + {/* Data Retention (Observability) */} +
+
+ + Data Retention +
+
+
+
+ +

How long to keep per-container CPU/RAM/network history.

+
+
+ onSettingChange('metrics_retention_hours', e.target.value)} + className="w-20" + /> + hrs +
+
+ +
+
+ +

How long to keep alert and notification history.

+
+
+ onSettingChange('log_retention_days', e.target.value)} + className="w-20" + /> + days +
+
+ + {isPro && license?.variant === 'team' && ( +
+
+ +

How long to keep audit trail entries.

+
+
+ onSettingChange('audit_retention_days', e.target.value)} + className="w-20" + /> + days +
+
+ )} +
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx new file mode 100644 index 00000000..53c20d1b --- /dev/null +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -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 ( +
+
+

License

+

Manage your Sencho Pro license.

+
+ + {/* Current Tier Display */} +
+
+
+ {license?.tier === 'pro' ? ( + + ) : ( + + )} + + {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'} + +
+ +
+ + {license?.status === 'trial' && license.trialDaysRemaining !== null && ( +
+ + Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining +
+ )} + + {license?.status === 'active' && ( +
+ {license.customerName && ( +
+ Customer + {license.customerName} +
+ )} + {license.productName && ( +
+ Plan + {license.productName} +
+ )} + {license.maskedKey && ( +
+ License Key + {license.maskedKey} +
+ )} + {license.validUntil && ( +
+ Renews + {new Date(license.validUntil).toLocaleDateString()} +
+ )} +
+ )} + + {license?.status === 'expired' && ( +
+ + Your Pro license has expired. Renew to restore Pro features. +
+ )} + + {license?.status === 'disabled' && ( +
+ + Your license has been disabled. Contact support for assistance. +
+ )} +
+ + {/* Manage Subscription (active Pro) */} + {license?.status === 'active' && ( +
+ {license.portalUrl && ( + + )} +
+

+ Deactivating will revert to Community features. +

+ +
+
+ )} + + {/* Upgrade Cards */} + {(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && ( +
+ +
+ {/* Skipper Card - only for Community users */} + {license?.tier !== 'pro' && ( +
+
+ + Skipper + Popular +
+

Professional tools for solo operators.

+
    + {['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+ )} + + {/* Admiral Card */} +
+
+ + Admiral +
+

For teams managing shared infrastructure.

+
    + {[ + ...(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) => ( +
  • + + {f} +
  • + ))} +
+ +
+
+
+ )} + + {/* License key activation */} + {license?.status !== 'active' && ( +
+ +
+ setLicenseKeyInput(e.target.value)} + className="font-mono" + /> + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx new file mode 100644 index 00000000..51eef9ea --- /dev/null +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -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>({ + discord: { type: 'discord', url: '', enabled: false }, + slack: { type: 'slack', url: '', enabled: false }, + webhook: { type: 'webhook', url: '', enabled: false }, + }); + const [isSavingAgent, setIsSavingAgent] = useState>({}); + const [isTestingAgent, setIsTestingAgent] = useState>({}); + + 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) => ( +
+
+ + handleAgentChange(type, 'enabled', c)} + /> +
+
+ + handleAgentChange(type, 'url', e.target.value)} + /> +
+
+ + +
+
+ ); + + return ( +
+
+
+

Notifications & Alerts

+

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

+
+ {isRemote && ( + + + + + + Remote + + + + These channels are saved on the remote Sencho instance and used when it dispatches alerts. + + + + )} +
+ setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> + + + + Discord + + + Slack + + + Webhook + + + + {renderAgentTab('discord', 'Discord')} + {renderAgentTab('slack', 'Slack')} + {renderAgentTab('webhook', 'Custom Webhook')} + +
+ ); +} diff --git a/frontend/src/components/settings/SupportSection.tsx b/frontend/src/components/settings/SupportSection.tsx new file mode 100644 index 00000000..05824b1b --- /dev/null +++ b/frontend/src/components/settings/SupportSection.tsx @@ -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 ( +
+
+

Help & Support

+

Get help with Sencho based on your plan.

+
+ + {/* Self-serve channels (all tiers) */} + + + {/* Pro support channels */} + {isPro && ( + + )} + + {/* Upsell for Community */} + {!isPro && ( +
+
+ +
+

Need faster support?

+

+ Upgrade to Pro for direct email support and priority issue handling. +

+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/settings/SystemSection.tsx b/frontend/src/components/settings/SystemSection.tsx new file mode 100644 index 00000000..044c9035 --- /dev/null +++ b/frontend/src/components/settings/SystemSection.tsx @@ -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: (key: K, value: PatchableSettings[K]) => void; + onSave: () => Promise; + isSaving: boolean; + isLoading: boolean; + isRemote: boolean; + activeNodeName?: string; +} + +function SettingsSkeleton() { + return ( +
+ + +
+ + + +
+
+ ); +} + +export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote, activeNodeName }: SystemSectionProps) { + return ( +
+
+
+

System Limits & Watchdog

+

Configure alert thresholds and crash detection.

+
+ {isRemote && ( + + + Configuring: {activeNodeName} + + )} +
+ + {isLoading ? : ( + <> +
+
+
+ + {settings.host_cpu_limit}% +
+ onSettingChange('host_cpu_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_ram_limit}% +
+ onSettingChange('host_ram_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_disk_limit}% +
+ onSettingChange('host_disk_limit', v[0].toString())} + /> +
+ +
+ +
+ onSettingChange('docker_janitor_gb', e.target.value)} + className="max-w-[150px]" + /> + GB reclaimable +
+

Alert when unused Docker data exceeds this size.

+
+ +
+
+ +

Watch all containers for unexpected exits

+
+ onSettingChange('global_crash', c ? '1' : '0')} + /> +
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx new file mode 100644 index 00000000..84ee66a9 --- /dev/null +++ b/frontend/src/components/settings/UsersSection.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [saving, setSaving] = useState(false); + + // Form state + const [formUsername, setFormUsername] = useState(''); + const [formPassword, setFormPassword] = useState(''); + const [formConfirmPassword, setFormConfirmPassword] = useState(''); + const [formRole, setFormRole] = useState('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 = { 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([]); + const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); + const [scopeResourceId, setScopeResourceId] = useState(''); + const [scopeRole, setScopeRole] = useState('deployer'); + const [availableStacks, setAvailableStacks] = useState([]); + 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 ( + +
+
+
+

User Management

+

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

+
+ {!showForm && ( + + )} +
+ + {/* Add/Edit Form */} + {showForm && ( +
+

{editingUser ? 'Edit User' : 'New User'}

+
+
+ + setFormUsername(e.target.value)} + placeholder="username" + /> +
+
+ + +
+
+
+
+ + setFormPassword(e.target.value)} + placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} + /> +
+
+ + setFormConfirmPassword(e.target.value)} + placeholder="Confirm password" + /> +
+
+
+ + +
+ + {/* Scoped Permissions (Admiral, editing only) */} + {editingUser && isPro && license?.variant === 'team' && ( +
+

Scoped Permissions

+

+ Grant additional permissions on specific stacks or nodes. These supplement the user's global role. +

+ + {roleAssignments.length > 0 && ( +
+ {roleAssignments.map((a) => ( +
+ + {a.role} + on {a.resource_type}: {a.resource_id} + + +
+ ))} +
+ )} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ )} +
+ )} + + {/* Users Table */} + {loading ? ( +
+ + +
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+ + + + + + + + + + + {users.map((u) => { + const isSelf = u.username === currentUser?.username; + return ( + + + + + + + ); + })} + +
UsernameRoleCreatedActions
+ {u.username} + {isSelf && (you)} + + + {u.role} + + + {new Date(u.created_at).toLocaleDateString()} + +
+ + + + + + + + Delete user "{u.username}"? + + This action cannot be undone. The user will lose access immediately. + + + + Cancel + handleDelete(u.id)}>Delete + + + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx new file mode 100644 index 00000000..dd9c2577 --- /dev/null +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -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([]); + 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(null); + const [history, setHistory] = useState>({}); + const [loadingHistory, setLoadingHistory] = useState(null); + + // Form state + const [formName, setFormName] = useState(''); + const [formStack, setFormStack] = useState(''); + const [formAction, setFormAction] = useState('deploy'); + const [stacks, setStacks] = useState([]); + + 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 ( +
+
+

Webhooks

+

Trigger stack actions from CI/CD pipelines via HTTP.

+
+ +
+
+
+
+ +
+ ); + } + + return ( +
+
+
+

Webhooks

+

Trigger stack actions from CI/CD pipelines via HTTP.

+
+ +
+ + {/* Create Form */} + {showForm && ( +
+
+ + setFormName(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ )} + + {/* Secret reveal (shown once after creation) */} + {newSecret && ( +
+
+ Webhook created - copy your secret now +
+

This secret will not be shown again. Store it securely.

+
+ {newSecret.secret} + +
+ +
+ )} + + {/* Loading state */} + {loading && ( +
+ + +
+ )} + + {/* Empty state */} + {!loading && webhooks.length === 0 && !showForm && ( +
+ +

No webhooks configured yet.

+

Create one to trigger stack actions from CI/CD.

+
+ )} + + {/* Webhook list */} + {!loading && webhooks.map(wh => { + const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`; + const isExpanded = expandedHistory === wh.id; + return ( +
+
+
+
+ + {wh.name} + {wh.action} + {wh.stack_name} +
+
+ handleToggle(wh.id!, c)} /> + +
+
+ + {/* Trigger URL */} +
+ +
+ {triggerUrl} + +
+
+ + {/* Secret (masked) */} +
+ Secret: + {wh.secret} +
+ + {/* History toggle */} + +
+ + {/* Execution history */} + {isExpanded && ( +
+ {loadingHistory === wh.id ? ( + + ) : (history[wh.id!] ?? []).length === 0 ? ( +

No executions yet.

+ ) : ( +
+ {(history[wh.id!] ?? []).map(ex => ( +
+ {ex.status === 'success' + ? + : } + {ex.action} + + {new Date(ex.executed_at).toLocaleString()} + + {ex.duration_ms !== null && ( + {(ex.duration_ms / 1000).toFixed(1)}s + )} + {ex.error && ( + {ex.error} + )} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts new file mode 100644 index 00000000..dd3a36bf --- /dev/null +++ b/frontend/src/components/settings/index.ts @@ -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'; diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts new file mode 100644 index 00000000..3fb187c6 --- /dev/null +++ b/frontend/src/components/settings/types.ts @@ -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; +} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx index 3a4ef9e8..7f5c44b8 100644 --- a/frontend/src/components/ui/alert-dialog.tsx +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -14,7 +14,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (({ className, ...props }, ref) => ( )); diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index d8f7202c..7ad15fd9 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -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: { diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index cabfbfc5..9a017643 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef<
(({ className, ...props }, ref) => (
)) diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx index 0cd4b2c0..5203e91e 100644 --- a/frontend/src/components/ui/context-menu.tsx +++ b/frontend/src/components/ui/context-menu.tsx @@ -44,7 +44,7 @@ const ContextMenuSubContent = React.forwardRef< >(({ className, children, ...props }, ref) => ( - + >( (({ className, ...props }, ref) => ( )) diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 272cb721..d0d706b9 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (({ className, ...props }, ref) => ( )) diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 35172ed6..0ebb2dc6 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -17,7 +17,7 @@ const TabsList = React.forwardRef<