From 7637091e84838047c462e3dbce38122d4c24d007 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 30 Mar 2026 12:48:42 -0400 Subject: [PATCH] feat(ui): glassmorphism redesign with settings decomposition (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add glassmorphism design tokens and utility classes Introduce glass design system foundation: translucent oklch color variables for both light and dark themes, glass/glass-border/glass-highlight tokens, semantic status colors (success/warning/info), .glass and .glass-strong utility classes with backdrop-filter, reduced shadow values, and standardized spring animation presets in lib/motion.ts. * feat(ui): apply glass treatment to core components Update card, dialog, input, button, popover, sheet, tooltip, dropdown-menu, context-menu, select, alert-dialog, and tabs components with glassmorphism styling: translucent backgrounds via new CSS variables, backdrop-blur layers, glass-border luminous edges, and glass-highlight hover states. * refactor(settings): decompose Settings Modal into section components Extract 10 inline sections from the 1,987-line SettingsModal into dedicated files under components/settings/. Introduce section registry pattern replacing 14 conditional blocks. Add shared types, sidebar navigation grouping with separators, glass treatment on sidebar and nav buttons, and responsive modal height. SettingsModal shell shrinks to ~380 lines. * refactor(ui): unify all tabs to animate-ui TabsHighlight with glass styling Migrate 4 tab instances (EditorLayout, FleetView, ResourcesView, NotificationsSection) from inconsistent patterns (manual layoutId, underline border-b-2, default fade) to the shared TabsHighlight primitive with glass-highlight indicator and springs.snappy transition. Standardize EditorLayout nav highlight spring config, apply glass-highlight to sidebar stack list hover/active states, and update mobile nav styling. * refactor(ui): migrate hardcoded colors to semantic CSS variables Replace hardcoded Tailwind color classes across ~19 component files with semantic CSS variable classes: emerald/green to success, orange/amber to warning, blue to info. Preserves brand/decorative colors (Crown amber, Admiral blue). Enables consistent theming of status indicators across the entire application. * refactor(ui): Linear dark precision aesthetic — solid surfaces, depth cues, text hierarchy Replace glassmorphism with Linear.app-inspired design: solid surface tokens (card #111111, sidebar #0d0d0d, root #0a0a0a), backdrop-blur restricted to floating overlays only (blur(10px) saturate(1.15)), desaturated teal accent, font-weight 500 everywhere, monochrome chart palette, and three depth cues: root ambient glow, luminous card top-edge, steep text brightness ramp. * refactor(ui): precision polish — fix muddy dark, snowblind light, add design anchors - Replace 34 hardcoded rgba values with theme-aware stat-* CSS tokens - Fix light theme: solid white cards, off-white background, readable text - Add card-border tokens with sharper directional lighting (top edge 2x) - Add chart-grid/chart-tick tokens for theme-aware axis rendering - Upgrade body glow: teal-tinted (dark), warm amber (light) - Terminal-inspired sidebar: monospaced UP/DN status codes, Geist Mono - Add tabular-nums to stat values to prevent layout jitter - Light mode cards get shadow-sm for depth against off-white background * refactor(ui): Linear materiality pass — ghosted nav, translucent sidebar, font unity - De-escalate Delete button from solid destructive to ghost with hover fill - Make sidebar translucent (80% opacity + backdrop-blur) so body glow bleeds through - Bump dark nav accent to 0.07 for ghosted backlit selection - Unify all terminal/editor fonts to Geist Mono (was JetBrains/Consolas mix) - Add Monaco editor fontFamily for YAML/env editing consistency - Add threshold-based color to Host RAM and Host Disk stat values (warn/crit) * refactor(ui): material simulation — inherent depth, layer separation, recessed terminal - Bump dark background 0.065→0.08, card surfaces 0.10→0.12 for 4% layer separation - Add card-bevel token (inset top shimmer) for permanent structural depth - Add button-inner-glow token for physical key feel on outline buttons - Recess terminal with inset shadow and dimmed label - Reduce action icon strokeWidth to 1.5 for refined industrial feel - Add teal LED backlight bar on active nav item via blur pseudo-element * fix(ui): parse usagePercent string to number for getValueColor usagePercent is typed as string in SystemStats but getValueColor expects number, causing TS2345 in CI builds. --- frontend/src/components/ApiTokensSection.tsx | 6 +- frontend/src/components/AuditLogView.tsx | 2 +- frontend/src/components/BashExecModal.tsx | 4 +- frontend/src/components/EditorLayout.tsx | 109 +- frontend/src/components/FleetSnapshots.tsx | 8 +- frontend/src/components/FleetView.tsx | 55 +- .../components/GlobalObservabilityView.tsx | 4 +- frontend/src/components/HomeDashboard.tsx | 96 +- frontend/src/components/HostConsole.tsx | 4 +- frontend/src/components/LogViewer.tsx | 2 +- frontend/src/components/Login.tsx | 2 +- frontend/src/components/NodeManager.tsx | 8 +- frontend/src/components/RegistriesSection.tsx | 4 +- frontend/src/components/ResourcesView.tsx | 88 +- frontend/src/components/SSOSection.tsx | 6 +- .../components/ScheduledOperationsView.tsx | 4 +- frontend/src/components/SettingsModal.tsx | 1880 ++--------------- frontend/src/components/Setup.tsx | 2 +- frontend/src/components/StackAlertSheet.tsx | 30 +- frontend/src/components/Terminal.tsx | 2 +- .../src/components/settings/AboutSection.tsx | 51 + .../components/settings/AccountSection.tsx | 54 + .../components/settings/AppStoreSection.tsx | 112 + .../components/settings/DeveloperSection.tsx | 180 ++ .../components/settings/LicenseSection.tsx | 243 +++ .../settings/NotificationsSection.tsx | 176 ++ .../components/settings/SupportSection.tsx | 92 + .../src/components/settings/SystemSection.tsx | 131 ++ .../src/components/settings/UsersSection.tsx | 457 ++++ .../components/settings/WebhooksSection.tsx | 322 +++ frontend/src/components/settings/index.ts | 12 + frontend/src/components/settings/types.ts | 49 + frontend/src/components/ui/alert-dialog.tsx | 6 +- frontend/src/components/ui/button.tsx | 6 +- frontend/src/components/ui/card.tsx | 4 +- frontend/src/components/ui/context-menu.tsx | 6 +- frontend/src/components/ui/dialog.tsx | 4 +- frontend/src/components/ui/dropdown-menu.tsx | 6 +- frontend/src/components/ui/input.tsx | 2 +- frontend/src/components/ui/popover.tsx | 2 +- frontend/src/components/ui/select.tsx | 4 +- frontend/src/components/ui/sheet.tsx | 6 +- frontend/src/components/ui/tabs.tsx | 2 +- frontend/src/components/ui/tooltip.tsx | 2 +- frontend/src/index.css | 284 ++- frontend/src/lib/motion.ts | 7 + 46 files changed, 2483 insertions(+), 2053 deletions(-) create mode 100644 frontend/src/components/settings/AboutSection.tsx create mode 100644 frontend/src/components/settings/AccountSection.tsx create mode 100644 frontend/src/components/settings/AppStoreSection.tsx create mode 100644 frontend/src/components/settings/DeveloperSection.tsx create mode 100644 frontend/src/components/settings/LicenseSection.tsx create mode 100644 frontend/src/components/settings/NotificationsSection.tsx create mode 100644 frontend/src/components/settings/SupportSection.tsx create mode 100644 frontend/src/components/settings/SystemSection.tsx create mode 100644 frontend/src/components/settings/UsersSection.tsx create mode 100644 frontend/src/components/settings/WebhooksSection.tsx create mode 100644 frontend/src/components/settings/index.ts create mode 100644 frontend/src/components/settings/types.ts create mode 100644 frontend/src/lib/motion.ts 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<