+
{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"
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 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.
- ) : (
-
-
-
-
- | Username |
- Role |
- Created |
- Actions |
-
-
-
- {users.map((u) => {
- const isSelf = u.username === currentUser?.username;
- return (
-
- |
- {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 (