mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
feat: RBAC, atomic deployments, and fleet-wide backups (Pro) (#181)
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro) Introduces three Pro-tier features: - RBAC: Multi-user system with admin/viewer roles, user management UI, automatic migration from single-admin credentials, viewer restrictions across the entire UI (read-only editor, hidden action buttons) - Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic rollback on health probe failure, manual rollback button, health probes added to stack updates, webhook-triggered deploys use atomic rollback - Fleet-Wide Backups: Point-in-time snapshots of compose files across all nodes (local + remote), stored centrally in SQLite, per-stack restore with optional redeploy, graceful handling of offline nodes * fix(settings): use correct ProGate prop name in UsersSection * fix(settings): remove unused isPro prop from UsersSection * fix(auth): fetch user info after login and setup so isAdmin is set correctly
This commit is contained in:
@@ -10,6 +10,7 @@ import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react"
|
||||
import { toast } from "sonner";
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export interface TemplateEnv {
|
||||
name: string;
|
||||
@@ -44,6 +45,7 @@ interface AppStoreViewProps {
|
||||
}
|
||||
|
||||
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -471,9 +473,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !stackName.trim()}
|
||||
disabled={isDeploying || !stackName.trim() || !isAdmin}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
title={!isAdmin ? 'Admin access required to deploy' : undefined}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2 } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -37,6 +37,8 @@ import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { FleetView } from './FleetView';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -69,6 +71,8 @@ const formatBytes = (bytes: number) => {
|
||||
};
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPro } = useLicense();
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
@@ -103,6 +107,7 @@ export default function EditorLayout() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
const [isFileLoading, setIsFileLoading] = useState(false);
|
||||
const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null });
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
@@ -624,6 +629,17 @@ export default function EditorLayout() {
|
||||
console.error('Failed to load containers:', error);
|
||||
setContainers([]);
|
||||
}
|
||||
|
||||
// Load backup info (Pro only)
|
||||
if (isPro) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${filename}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
else setBackupInfo({ exists: false, timestamp: null });
|
||||
} catch {
|
||||
setBackupInfo({ exists: false, timestamp: null });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
setSelectedFile(null);
|
||||
@@ -678,6 +694,32 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackStack = async () => {
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
setLoadingAction('rollback');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${selectedFile}/rollback`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.error || 'Rollback failed');
|
||||
}
|
||||
toast.success('Stack rolled back successfully.');
|
||||
// Reload the editor content
|
||||
const contentRes = await apiFetch(`/stacks/${selectedFile}`);
|
||||
const text = await contentRes.text();
|
||||
setContent(text || '');
|
||||
setOriginalContent(text || '');
|
||||
// Refresh backup info
|
||||
const backupRes = await apiFetch(`/stacks/${selectedFile}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||||
await saveFile();
|
||||
await deployStack(e);
|
||||
@@ -716,9 +758,17 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
// Refresh backup info
|
||||
if (isPro) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
toast.error((error as Error).message || 'Failed to deploy stack');
|
||||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||||
toast.error(isPro ? `${msg} — automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -969,7 +1019,7 @@ export default function EditorLayout() {
|
||||
)}
|
||||
|
||||
{/* Create Stack Button */}
|
||||
<div className="p-4">
|
||||
{isAdmin && <div className="p-4">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full rounded-lg">
|
||||
@@ -995,7 +1045,7 @@ export default function EditorLayout() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Search Input & Stack List */}
|
||||
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
||||
@@ -1119,6 +1169,7 @@ export default function EditorLayout() {
|
||||
Fleet
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -1128,6 +1179,7 @@ export default function EditorLayout() {
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
)}
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'resources' ? 'default' : 'outline'}
|
||||
@@ -1263,6 +1315,7 @@ export default function EditorLayout() {
|
||||
{/* Stack Name */}
|
||||
<CardTitle className="text-2xl font-bold">{stackName}</CardTitle>
|
||||
{/* Action Bar */}
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isRunning ? (
|
||||
<>
|
||||
@@ -1285,6 +1338,23 @@ export default function EditorLayout() {
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
{isPro && backupInfo.exists && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={rollbackStack} disabled={loadingAction !== null}>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{backupInfo.timestamp
|
||||
? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}`
|
||||
: 'Roll back to previous deployment'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1300,6 +1370,7 @@ export default function EditorLayout() {
|
||||
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
@@ -1391,6 +1462,7 @@ export default function EditorLayout() {
|
||||
<TooltipContent>View Live Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1407,6 +1479,7 @@ export default function EditorLayout() {
|
||||
<TooltipContent>Open Bash Terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1464,6 +1537,7 @@ export default function EditorLayout() {
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
{!isEditing ? (
|
||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||
@@ -1487,6 +1561,7 @@ export default function EditorLayout() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === 'env' && (
|
||||
@@ -1517,7 +1592,7 @@ export default function EditorLayout() {
|
||||
fontSize: 14,
|
||||
padding: { top: 10 },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: !isEditing,
|
||||
readOnly: !isEditing || !isAdmin,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
|
||||
Eye, ChevronDown, ChevronRight, Plus, Loader2, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface FleetSnapshot {
|
||||
id: number;
|
||||
description: string;
|
||||
created_by: string;
|
||||
node_count: number;
|
||||
stack_count: number;
|
||||
skipped_nodes: string; // JSON string
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SnapshotStackFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SnapshotStack {
|
||||
stackName: string;
|
||||
files: SnapshotStackFile[];
|
||||
}
|
||||
|
||||
interface SnapshotNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stacks: SnapshotStack[];
|
||||
}
|
||||
|
||||
interface FleetSnapshotDetail extends FleetSnapshot {
|
||||
nodes: SnapshotNode[];
|
||||
}
|
||||
|
||||
interface SkippedNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function FleetSnapshots() {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState<FleetSnapshotDetail | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'detail'>('list');
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
|
||||
const [previewFiles, setPreviewFiles] = useState<Set<string>>(new Set());
|
||||
const [restoringStack, setRestoringStack] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
// --- Data Fetching ---
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/snapshots', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data: { snapshots: FleetSnapshot[]; total: number } = await res.json();
|
||||
setSnapshots(data.snapshots);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshots.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSnapshots();
|
||||
}, [fetchSnapshots]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/snapshots', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ description: description.trim() || undefined }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Snapshot created successfully.');
|
||||
setShowCreateForm(false);
|
||||
setDescription('');
|
||||
await fetchSnapshots();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to create snapshot.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (snapshot: FleetSnapshot) => {
|
||||
setLoadingDetail(true);
|
||||
setViewMode('detail');
|
||||
setExpandedNodes(new Set());
|
||||
setExpandedStacks(new Set());
|
||||
setPreviewFiles(new Set());
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${snapshot.id}`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data: FleetSnapshotDetail = await res.json();
|
||||
setSelectedSnapshot(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshot details.');
|
||||
setViewMode('list');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
setViewMode('list');
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${id}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Snapshot deleted.');
|
||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to delete snapshot.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => {
|
||||
if (!selectedSnapshot) return;
|
||||
const key = `${nodeId}:${stackName}`;
|
||||
setRestoringStack(key);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ nodeId, stackName, redeploy }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { message: string; redeployed: boolean } = await res.json();
|
||||
toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.');
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setRestoringStack(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Toggle helpers ---
|
||||
|
||||
const toggleNode = (nodeId: number) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStack = (key: string) => {
|
||||
setExpandedStacks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const togglePreview = (key: string) => {
|
||||
setPreviewFiles(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// --- Parse skipped nodes safely ---
|
||||
|
||||
function parseSkippedNodes(raw: string): SkippedNode[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed as SkippedNode[];
|
||||
} catch { /* invalid JSON */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Detail View ---
|
||||
|
||||
if (viewMode === 'detail') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 -ml-2"
|
||||
onClick={() => { setViewMode('list'); setSelectedSnapshot(null); }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Snapshots
|
||||
</Button>
|
||||
|
||||
{loadingDetail ? (
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : selectedSnapshot ? (
|
||||
<>
|
||||
{/* Header card */}
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedSnapshot.description || 'Untitled Snapshot'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created by {selectedSnapshot.created_by} on{' '}
|
||||
{new Date(selectedSnapshot.created_at).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skipped nodes warning */}
|
||||
{(() => {
|
||||
const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes);
|
||||
if (skipped.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
Some nodes were unreachable during snapshot creation:
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 space-y-1">
|
||||
{skipped.map(node => (
|
||||
<li key={node.nodeId} className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{node.nodeName}</span>
|
||||
{' — '}
|
||||
{node.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Node / Stack / File tree */}
|
||||
<div className="space-y-2">
|
||||
{selectedSnapshot.nodes.map(node => {
|
||||
const nodeExpanded = expandedNodes.has(node.nodeId);
|
||||
return (
|
||||
<div key={node.nodeId} className="rounded-xl border bg-card overflow-hidden">
|
||||
{/* Node header */}
|
||||
<button
|
||||
onClick={() => toggleNode(node.nodeId)}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{nodeExpanded
|
||||
? <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Server className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 truncate">{node.nodeName}</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{node.stacks.length} stack{node.stacks.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Stacks */}
|
||||
{nodeExpanded && (
|
||||
<div className="border-t px-2 pb-3">
|
||||
{node.stacks.map(stack => {
|
||||
const stackKey = `${node.nodeId}:${stack.stackName}`;
|
||||
const stackExpanded = expandedStacks.has(stackKey);
|
||||
return (
|
||||
<div key={stackKey}>
|
||||
<button
|
||||
onClick={() => toggleStack(stackKey)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{stackExpanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium flex-1 truncate">
|
||||
{stack.stackName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Files */}
|
||||
{stackExpanded && (
|
||||
<div className="ml-6 space-y-1 mt-1">
|
||||
{stack.files.map(file => {
|
||||
const fileKey = `${stackKey}:${file.filename}`;
|
||||
const showPreview = previewFiles.has(fileKey);
|
||||
return (
|
||||
<div key={fileKey}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs flex-1 truncate">{file.filename}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => togglePreview(fileKey)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
{showPreview ? 'Hide' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
{showPreview && (
|
||||
<pre className="mx-3 mt-1 mb-2 p-3 bg-zinc-950 text-zinc-200 text-xs font-mono rounded-lg overflow-auto max-h-64 whitespace-pre-wrap break-words">
|
||||
{file.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Restore button (admin only) */}
|
||||
{isAdmin && (
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- List View ---
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Camera className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Fleet Snapshots</h2>
|
||||
</div>
|
||||
{isAdmin && !showCreateForm && (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Snapshot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreateForm && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Snapshot description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={creating}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating} className="gap-1.5">
|
||||
{creating && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowCreateForm(false); setDescription(''); }}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-48 flex-1" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Camera className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-sm font-medium mb-1">No snapshots yet</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
Create your first fleet snapshot to back up compose files across all nodes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Snapshots table */
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Warnings</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map(snapshot => {
|
||||
const skipped = parseSkippedNodes(snapshot.skipped_nodes);
|
||||
const skippedNames = skipped.map(s => s.nodeName).join(', ');
|
||||
return (
|
||||
<TableRow key={snapshot.id}>
|
||||
<TableCell className="text-xs whitespace-nowrap">
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[300px] truncate">
|
||||
{snapshot.description ? (
|
||||
snapshot.description
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">No description</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skipped.length > 0 ? (
|
||||
<span
|
||||
className="flex items-center gap-1 text-amber-500"
|
||||
title={`Skipped: ${skippedNames}`}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">{skipped.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleViewDetail(snapshot)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-red-500 hover:bg-red-500/10"
|
||||
disabled={deletingId === snapshot.id}
|
||||
>
|
||||
{deletingId === snapshot.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this fleet snapshot. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleDelete(snapshot.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Restore Button Sub-Component ---
|
||||
|
||||
function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
restoring: boolean;
|
||||
onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [redeploy, setRedeploy] = useState(false);
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs gap-1.5 ml-3 mt-1"
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Restore {stackName} on {nodeName}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will overwrite the current compose files with the snapshot version.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id={`redeploy-${nodeId}-${stackName}`}
|
||||
checked={redeploy}
|
||||
onCheckedChange={(checked) => setRedeploy(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`redeploy-${nodeId}-${stackName}`}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Redeploy stack after restore
|
||||
</Label>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={restoring}
|
||||
onClick={() => onRestore(nodeId, stackName, redeploy)}
|
||||
>
|
||||
{restoring && <Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" />}
|
||||
Restore
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity,
|
||||
Play, Square, RotateCcw, ExternalLink,
|
||||
Play, Square, RotateCcw, ExternalLink, Camera,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,9 +11,11 @@ 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 { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from './ProGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// --- Types ---
|
||||
@@ -567,210 +569,229 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && nodes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Server className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No nodes configured</h3>
|
||||
<p className="text-sm text-muted-foreground">Add nodes in Settings to see your fleet here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
value={`${totalContainers}`}
|
||||
sub={`${totalContainersAll} total across fleet`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Fleet CPU"
|
||||
value={`${avgCpu}%`}
|
||||
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="Fleet Memory"
|
||||
value={formatBytes(totalMemUsed)}
|
||||
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts"
|
||||
value={`${criticalCount}`}
|
||||
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
||||
alert={criticalCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
{isPro && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="cpu">CPU Usage</SelectItem>
|
||||
<SelectItem value="memory">Memory Usage</SelectItem>
|
||||
<SelectItem value="containers">Containers</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterStatus: status })}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'online' ? (
|
||||
<><Play className="w-3 h-3 mr-1" />Online</>
|
||||
) : (
|
||||
<><Square className="w-3 h-3 mr-1" />Offline</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={prefs.filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterType: type })}
|
||||
>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Critical Only
|
||||
</Button>
|
||||
</div>
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Node Grid */}
|
||||
{processedNodes.length > 0 ? (
|
||||
<TabsContent value="overview">
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{processedNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
/>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
||||
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && nodes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Server className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No nodes configured</h3>
|
||||
<p className="text-sm text-muted-foreground">Add nodes in Settings to see your fleet here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
value={`${totalContainers}`}
|
||||
sub={`${totalContainersAll} total across fleet`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Fleet CPU"
|
||||
value={`${avgCpu}%`}
|
||||
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="Fleet Memory"
|
||||
value={formatBytes(totalMemUsed)}
|
||||
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts"
|
||||
value={`${criticalCount}`}
|
||||
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
||||
alert={criticalCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
{isPro && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="cpu">CPU Usage</SelectItem>
|
||||
<SelectItem value="memory">Memory Usage</SelectItem>
|
||||
<SelectItem value="containers">Containers</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterStatus: status })}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'online' ? (
|
||||
<><Play className="w-3 h-3 mr-1" />Online</>
|
||||
) : (
|
||||
<><Square className="w-3 h-3 mr-1" />Offline</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={prefs.filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterType: type })}
|
||||
>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Critical Only
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
)}
|
||||
|
||||
{/* Node Grid */}
|
||||
{processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{processedNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
||||
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{isPro && (
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -305,6 +306,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ResourcesView() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
@@ -485,7 +487,7 @@ export default function ResourcesView() {
|
||||
</Card>
|
||||
|
||||
{/* Quick Clean */}
|
||||
<Card className="col-span-1 md:col-span-2 border-border shadow-sm flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
{isAdmin && <Card className="col-span-1 md:col-span-2 border-border shadow-sm flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
||||
Quick Clean
|
||||
@@ -531,7 +533,7 @@ export default function ResourcesView() {
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
</div>
|
||||
|
||||
{/* Resource Tabs */}
|
||||
@@ -609,9 +611,9 @@ export default function ResourcesView() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -656,9 +658,9 @@ export default function ResourcesView() {
|
||||
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
|
||||
<TableCell><ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -705,7 +707,7 @@ export default function ResourcesView() {
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
|
||||
<TableCell><ManagedBadge status={net.managedStatus} managedBy={net.managedBy} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors disabled:opacity-30"
|
||||
@@ -713,7 +715,7 @@ export default function ResourcesView() {
|
||||
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -18,10 +18,12 @@ import { Badge } from '@/components/ui/badge';
|
||||
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, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History } from 'lucide-react';
|
||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil } 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 { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProBadge } from './ProBadge';
|
||||
import { ProGate } from './ProGate';
|
||||
@@ -46,7 +48,7 @@ interface PatchableSettings {
|
||||
log_retention_days?: string;
|
||||
}
|
||||
|
||||
type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
|
||||
interface WebhookItem {
|
||||
id: number;
|
||||
@@ -372,8 +374,279 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'viewer';
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function UsersSection() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formUsername, setFormUsername] = useState('');
|
||||
const [formPassword, setFormPassword] = useState('');
|
||||
const [formConfirmPassword, setFormConfirmPassword] = useState('');
|
||||
const [formRole, setFormRole] = useState<'admin' | 'viewer'>('viewer');
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/users', { localOnly: true });
|
||||
if (res.ok) setUsers(await res.json());
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchUsers(); }, []);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormUsername('');
|
||||
setFormPassword('');
|
||||
setFormConfirmPassword('');
|
||||
setFormRole('viewer');
|
||||
setEditingUser(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formUsername || formUsername.length < 3) {
|
||||
toast.error('Username must be at least 3 characters.');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) {
|
||||
toast.error('Username can only contain letters, numbers, underscores, and hyphens.');
|
||||
return;
|
||||
}
|
||||
if (!editingUser && !formPassword) {
|
||||
toast.error('Password is required for new users.');
|
||||
return;
|
||||
}
|
||||
if (formPassword && formPassword.length < 6) {
|
||||
toast.error('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (formPassword && formPassword !== formConfirmPassword) {
|
||||
toast.error('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingUser) {
|
||||
const body: Record<string, string> = { username: formUsername, role: formRole };
|
||||
if (formPassword) body.password = formPassword;
|
||||
const res = await apiFetch(`/users/${editingUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to update user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User updated.');
|
||||
} else {
|
||||
const res = await apiFetch('/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }),
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to create user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User created.');
|
||||
}
|
||||
resetForm();
|
||||
fetchUsers();
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to delete user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User deleted.');
|
||||
fetchUsers();
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (u: UserItem) => {
|
||||
setEditingUser(u);
|
||||
setFormUsername(u.username);
|
||||
setFormRole(u.role);
|
||||
setFormPassword('');
|
||||
setFormConfirmPassword('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProGate featureName="User management">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">User Management</h3>
|
||||
<p className="text-sm text-muted-foreground">Create and manage user accounts with role-based access control.</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />Add User
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<h4 className="text-sm font-medium">{editingUser ? 'Edit User' : 'New User'}</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={formRole} onValueChange={(v) => setFormRole(v as 'admin' | 'viewer')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Confirm Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formConfirmPassword}
|
||||
onChange={(e) => setFormConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">No users found.</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/30 border-b border-border">
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const isSelf = u.username === currentUser?.username;
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-border last:border-0 hover:bg-muted/10">
|
||||
<td className="px-4 py-2.5 font-medium">
|
||||
{u.username}
|
||||
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'} className="text-xs capitalize">
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={isSelf}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The user will lose access immediately.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProGate>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPro, activate, deactivate } = useLicense();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('account');
|
||||
@@ -383,7 +656,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
setActiveSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -721,6 +994,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
{!isRemote && (
|
||||
<NavButton section="license" icon={<Crown className="w-4 h-4 mr-2" />} label="License" />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" />
|
||||
)}
|
||||
<NavButton
|
||||
section="system"
|
||||
icon={<Activity className="w-4 h-4 mr-2" />}
|
||||
@@ -1092,6 +1368,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
<WebhooksSection isPro={isPro} />
|
||||
)}
|
||||
|
||||
{activeSection === 'users' && (
|
||||
<UsersSection />
|
||||
)}
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from '
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface StackAlert {
|
||||
id?: number;
|
||||
@@ -39,6 +40,7 @@ interface AgentStatus {
|
||||
}
|
||||
|
||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
@@ -266,7 +268,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
@@ -274,7 +276,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -284,7 +286,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
<hr />
|
||||
|
||||
{/* Add New Alert Form */}
|
||||
<div className="space-y-4">
|
||||
{isAdmin && <div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold">Add New Rule</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -419,7 +421,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
'Add Rule'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SheetContent>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface UserProfileDropdownProps {
|
||||
}
|
||||
|
||||
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
||||
const { logout } = useAuth();
|
||||
const { logout, user, isAdmin } = useAuth();
|
||||
const { license, isPro } = useLicense();
|
||||
|
||||
return (
|
||||
@@ -33,8 +33,12 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">admin</p>
|
||||
<p className="text-sm font-medium truncate">{user?.username ?? 'admin'}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase ${isAdmin ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{user?.role ?? 'admin'}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
{isPro ? <ProBadge /> : <span>Community</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user