mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
809bf76c20
Extract duplicated snapshot capture functions from index.ts and SchedulerService.ts into a shared module (snapshot-capture.ts). Fix audit log route patterns that used singular 'snapshot' instead of plural 'snapshots'. Apply design system to FleetSnapshots component (card styling, strokeWidth, font-mono, tabular-nums, ScrollArea). Add safePage pagination, loading toast for creation, and fix the restore dialog race condition with a controlled AlertDialog. Add diagnostic logging gated behind Developer Mode. Add tests for restore endpoint, admin role enforcement, and edge cases. Update docs with troubleshooting section and refresh screenshots.
690 lines
36 KiB
TypeScript
690 lines
36 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
|
|
Eye, ChevronDown, ChevronLeft, 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 { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
// --- 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);
|
|
const [page, setPage] = useState(0);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
const pagedSnapshots = snapshots.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
|
const needsPagination = snapshots.length > PAGE_SIZE;
|
|
|
|
// --- 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);
|
|
const loadingId = toast.loading('Creating fleet snapshot...');
|
|
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 {
|
|
toast.dismiss(loadingId);
|
|
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" strokeWidth={1.5} />
|
|
Back to Snapshots
|
|
</Button>
|
|
|
|
{loadingDetail ? (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel 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-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel 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" className="font-mono tabular-nums">
|
|
{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''}
|
|
</Badge>
|
|
<Badge variant="secondary" className="font-mono tabular-nums">
|
|
{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-warning/30 bg-warning/5 p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
|
<span className="text-sm font-medium text-warning">
|
|
Some nodes were unreachable during snapshot creation:
|
|
</span>
|
|
</div>
|
|
<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-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden transition-colors hover:border-t-card-border-hover">
|
|
{/* 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 font-mono tabular-nums 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-mono font-medium flex-1 truncate">
|
|
{stack.stackName}
|
|
</span>
|
|
<Badge variant="outline" className="text-[10px] font-mono tabular-nums 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 font-mono 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" strokeWidth={1.5} />
|
|
{showPreview ? 'Hide' : 'Preview'}
|
|
</Button>
|
|
</div>
|
|
{showPreview && (
|
|
<ScrollArea className="mx-3 mt-1 mb-2 max-h-64 rounded-lg bg-background shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
|
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-words">
|
|
{file.content}
|
|
</pre>
|
|
</ScrollArea>
|
|
)}
|
|
</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" strokeWidth={1.5} />
|
|
<h2 className="text-lg font-semibold">Fleet Snapshots</h2>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{needsPagination && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
|
|
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
|
{safePage + 1} / {totalPages}
|
|
</span>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)}>
|
|
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{isAdmin && !showCreateForm && (
|
|
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
|
|
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
|
Create Snapshot
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create form */}
|
|
{showCreateForm && (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel 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-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
|
|
<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-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel 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>
|
|
{pagedSnapshots.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 font-mono tabular-nums 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 font-mono tabular-nums 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-warning"
|
|
title={`Skipped: ${skippedNames}`}
|
|
>
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
<span className="text-xs font-mono tabular-nums">{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" strokeWidth={1.5} />
|
|
View
|
|
</Button>
|
|
{isAdmin && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
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" strokeWidth={1.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);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
|
<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" strokeWidth={1.5} />
|
|
)}
|
|
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>
|
|
<Button
|
|
disabled={restoring}
|
|
onClick={async () => {
|
|
try {
|
|
await onRestore(nodeId, stackName, redeploy);
|
|
} finally {
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
{restoring && <Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" />}
|
|
Restore
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|