feat(fleet-snapshots): add restore-all, per-file download, and scrollable preview (#1276)

Three improvements to the Fleet Snapshots detail view, matching the admin-only
access of the existing per-stack restore:

- Restore all: a control in the snapshot header restores every captured stack
  across the fleet in one action, with an optional "redeploy all after restore"
  checkbox. Each stack is restored independently, so a failure on one (removed
  node, offline remote, blocked deploy) is reported per stack while the rest
  still proceed. Backed by POST /api/fleet/snapshots/:id/restore-all.
- Per-file download: each compose or .env file in a snapshot can be saved to
  disk individually from its row.
- Scrollable preview: the inline file preview is now a bounded, scrollable
  panel, so a long compose file can be read in full instead of being clipped.

Remote restore and redeploy failures now carry the remote node's status and
reason, so a per-stack failure in Restore all is actionable.
This commit is contained in:
Anso
2026-06-02 08:58:54 -04:00
committed by GitHub
parent 9fb4ccccff
commit e60c1c0525
4 changed files with 594 additions and 83 deletions
+145 -11
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import {
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
Cloud, CloudUpload,
Cloud, CloudUpload, Download,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -14,7 +14,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ConfirmModal } from '@/components/ui/modal';
import { ScrollArea } from '@/components/ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
@@ -93,6 +92,7 @@ export default function FleetSnapshots() {
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
const [previewFiles, setPreviewFiles] = useState<Set<string>>(new Set());
const [restoringStack, setRestoringStack] = useState<string | null>(null);
const [restoringAll, setRestoringAll] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [page, setPage] = useState(0);
@@ -273,6 +273,65 @@ export default function FleetSnapshots() {
}
};
const handleDownloadFile = (stackName: string, file: SnapshotStackFile) => {
try {
const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${stackName}-${file.filename}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (error: unknown) {
const err = error as Record<string, unknown> | null;
toast.error((err?.message as string) || 'Download failed.');
}
};
const handleRestoreAll = async (redeploy: boolean) => {
if (!selectedSnapshot) return;
setRestoringAll(true);
try {
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore-all`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ redeploy }),
});
if (res.ok) {
const data: {
restored: number;
failed: number;
redeploy: boolean;
results: Array<{ stackName: string; success: boolean; error?: string }>;
} = await res.json();
const noun = (n: number) => `${n} stack${n === 1 ? '' : 's'}`;
const firstFailed = data.results?.find(r => !r.success);
const failDetail = firstFailed
? ` First failure: ${firstFailed.stackName} · ${firstFailed.error || 'unknown error'}`
: '';
if (data.failed === 0) {
toast.success(data.redeploy
? `Restored and redeployed ${noun(data.restored)}.`
: `Restored ${noun(data.restored)}.`);
} else if (data.restored === 0) {
toast.error(`Restore failed for ${noun(data.failed)}.${failDetail}`);
} else {
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}`);
}
} else {
const err = await res.json().catch(() => null);
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore 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 {
setRestoringAll(false);
}
};
// --- Toggle helpers ---
const toggleNode = (nodeId: number) => {
@@ -342,13 +401,20 @@ export default function FleetSnapshots() {
<>
{/* 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-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<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>
{isAdmin && selectedSnapshot.nodes.length > 0 && (
<RestoreAllButton restoring={restoringAll} onRestoreAll={handleRestoreAll} />
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono tabular-nums">
{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''}
@@ -478,13 +544,22 @@ export default function FleetSnapshots() {
<Eye className="w-3 h-3 mr-1" strokeWidth={1.5} />
{showPreview ? 'Hide' : 'Preview'}
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleDownloadFile(stack.stackName, file)}
>
<Download className="w-3 h-3 mr-1" strokeWidth={1.5} />
Download
</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)]">
<div className="mx-3 mt-1 mb-2 max-h-[480px] overflow-y-auto 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>
)}
</div>
);
@@ -799,3 +874,62 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
</>
);
}
// --- Restore All Button Sub-Component ---
function RestoreAllButton({ restoring, onRestoreAll }: {
restoring: boolean;
onRestoreAll: (redeploy: boolean) => Promise<void>;
}) {
const [redeploy, setRedeploy] = useState(false);
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 shrink-0"
disabled={restoring}
onClick={() => setOpen(true)}
>
{restoring ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" strokeWidth={1.5} />
)}
Restore all
</Button>
<ConfirmModal
open={open}
onOpenChange={setOpen}
variant="destructive"
kicker="SNAPSHOTS · RESTORE ALL"
title="Restore all stacks"
confirmLabel={restoring ? 'Restoring...' : 'Restore all'}
confirming={restoring}
onConfirm={async () => {
try {
await onRestoreAll(redeploy);
} finally {
setOpen(false);
}
}}
>
<p className="text-sm text-stat-subtitle">
Overwrites the current compose and environment files for every stack on every node in this snapshot.
</p>
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id="redeploy-all"
checked={redeploy}
onCheckedChange={(checked) => setRedeploy(checked === true)}
/>
<Label htmlFor="redeploy-all" className="text-sm cursor-pointer">
Redeploy all stacks after restore
</Label>
</div>
</ConfirmModal>
</>
);
}