diff --git a/backend/src/__tests__/fleet.test.ts b/backend/src/__tests__/fleet.test.ts index 8bb30d96..af73a687 100644 --- a/backend/src/__tests__/fleet.test.ts +++ b/backend/src/__tests__/fleet.test.ts @@ -358,3 +358,183 @@ describe('Fleet snapshot lifecycle', () => { expect(check.status).toBe(404); }); }); + +// ─── Snapshot Restore Endpoint ─── + +describe('Fleet snapshot restore', () => { + afterEach(() => vi.restoreAllMocks()); + + let snapshotId: number; + + beforeAll(async () => { + const { LicenseService: LS } = await import('../services/LicenseService'); + vi.spyOn(LS.getInstance(), 'getTier').mockReturnValue('paid'); + const res = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', authHeader) + .send({ description: 'Restore test snapshot' }); + snapshotId = res.body.id; + vi.restoreAllMocks(); + }); + + it('POST /api/fleet/snapshots/:id/restore returns 401 without auth', async () => { + const res = await request(app) + .post(`/api/fleet/snapshots/${snapshotId}/restore`) + .send({ nodeId: 1, stackName: 'test' }); + expect(res.status).toBe(401); + }); + + it('POST /api/fleet/snapshots/:id/restore returns 403 on free tier', async () => { + mockTier('community'); + const res = await request(app) + .post(`/api/fleet/snapshots/${snapshotId}/restore`) + .set('Authorization', authHeader) + .send({ nodeId: 1, stackName: 'test' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('returns 400 with missing nodeId/stackName', async () => { + mockTier('paid'); + const res = await request(app) + .post(`/api/fleet/snapshots/${snapshotId}/restore`) + .set('Authorization', authHeader) + .send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/nodeId and stackName are required/i); + }); + + it('returns 400 with invalid stackName (path traversal)', async () => { + mockTier('paid'); + const res = await request(app) + .post(`/api/fleet/snapshots/${snapshotId}/restore`) + .set('Authorization', authHeader) + .send({ nodeId: 1, stackName: '../etc/passwd' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid stack name/i); + }); + + it('returns 400 for NaN snapshot ID', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots/abc/restore') + .set('Authorization', authHeader) + .send({ nodeId: 1, stackName: 'mystack' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid snapshot id/i); + }); + + it('returns 404 for non-existent snapshot', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots/99999/restore') + .set('Authorization', authHeader) + .send({ nodeId: 1, stackName: 'mystack' }); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/snapshot not found/i); + }); + + it('returns 404 when no files match the nodeId/stackName combo', async () => { + mockTier('paid'); + const res = await request(app) + .post(`/api/fleet/snapshots/${snapshotId}/restore`) + .set('Authorization', authHeader) + .send({ nodeId: 999, stackName: 'nonexistent-stack' }); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/no files found/i); + }); +}); + +// ─── Snapshot Admin Role Enforcement ─── + +describe('Fleet snapshot admin enforcement', () => { + afterEach(() => vi.restoreAllMocks()); + + let viewerHeader: string; + + beforeAll(async () => { + const { DatabaseService: DS } = await import('../services/DatabaseService'); + const db = DS.getInstance(); + const bcrypt = await import('bcrypt'); + const viewerHash = await bcrypt.hash('snapshotviewer', 1); + try { + db.addUser({ username: 'snapshotviewer', password_hash: viewerHash, role: 'viewer' }); + } catch { + // User may already exist + } + const viewerToken = jwt.sign({ username: 'snapshotviewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + viewerHeader = `Bearer ${viewerToken}`; + }); + + it('POST /api/fleet/snapshots returns 403 for viewer', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', viewerHeader) + .send({ description: 'test' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + + it('DELETE /api/fleet/snapshots/1 returns 403 for viewer', async () => { + mockTier('paid'); + const res = await request(app) + .delete('/api/fleet/snapshots/1') + .set('Authorization', viewerHeader); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + + it('POST /api/fleet/snapshots/1/restore returns 403 for viewer', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots/1/restore') + .set('Authorization', viewerHeader) + .send({ nodeId: 1, stackName: 'test' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + + it('GET /api/fleet/snapshots succeeds for viewer (read-only)', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/snapshots') + .set('Authorization', viewerHeader); + expect(res.status).toBe(200); + }); +}); + +// ─── Snapshot Edge Cases ─── + +describe('Fleet snapshot edge cases', () => { + afterEach(() => vi.restoreAllMocks()); + + it('snapshot with 0 stacks captured (empty COMPOSE_DIR)', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', authHeader) + .send({ description: 'Empty snapshot' }); + expect(res.status).toBe(201); + expect(res.body.stack_count).toBe(0); + }); + + it('DELETE on already-deleted snapshot returns 404', async () => { + mockTier('paid'); + const createRes = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', authHeader) + .send({ description: 'To delete twice' }); + const id = createRes.body.id; + + await request(app) + .delete(`/api/fleet/snapshots/${id}`) + .set('Authorization', authHeader); + + mockTier('paid'); + const res = await request(app) + .delete(`/api/fleet/snapshots/${id}`) + .set('Authorization', authHeader); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 33f384eb..cf330112 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -60,6 +60,7 @@ function invalidateNodeCaches(nodeId: number): void { import { isDebugEnabled } from './utils/debug'; import { getErrorMessage } from './utils/errors'; +import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture'; import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing'; import SelfUpdateService from './services/SelfUpdateService'; import semver from 'semver'; @@ -905,9 +906,9 @@ const AUDIT_ROUTE_SUMMARIES: Record = { 'PUT /webhooks': 'Updated webhook', 'DELETE /webhooks': 'Deleted webhook', 'PUT /settings': 'Updated settings', - 'POST /fleet/snapshot': 'Created fleet backup', - 'DELETE /fleet/snapshot': 'Deleted fleet backup', - 'POST /fleet/snapshot/restore': 'Restored fleet backup', + 'POST /fleet/snapshots': 'Created fleet backup', + 'DELETE /fleet/snapshots': 'Deleted fleet backup', + 'POST /fleet/snapshots/*/restore': 'Restored fleet backup', 'PUT /sso/config': 'Updated SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration', 'POST /api-tokens': 'Created API token', @@ -2017,93 +2018,6 @@ async function fetchRemoteNodeOverview(node: Node): Promise { // ─── Fleet Snapshots (Skipper+) ─── -interface SnapshotNodeData { - nodeId: number; - nodeName: string; - stacks: Array<{ - stackName: string; - files: Array<{ filename: string; content: string }>; - }>; -} - -async function captureLocalNodeFiles(node: Node): Promise { - const fsService = FileSystemService.getInstance(node.id); - const stackNames = await fsService.getStacks(); - const stacks: SnapshotNodeData['stacks'] = []; - - for (const stackName of stackNames) { - const files: Array<{ filename: string; content: string }> = []; - try { - const composeContent = await fsService.getStackContent(stackName); - files.push({ filename: 'compose.yaml', content: composeContent }); - } catch (e) { - console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message); - continue; - } - try { - const envContent = await fsService.getEnvContent(stackName); - files.push({ filename: '.env', content: envContent }); - } catch { - // No .env file - that's fine - } - stacks.push({ stackName, files }); - } - - return { nodeId: node.id, nodeName: node.name, stacks }; -} - -async function captureRemoteNodeFiles(node: Node): Promise { - if (!node.api_url || !node.api_token) { - throw new Error('Remote node not configured'); - } - - const baseUrl = node.api_url.replace(/\/$/, ''); - const headers = { Authorization: `Bearer ${node.api_token}` }; - - const stacksRes = await fetch(`${baseUrl}/api/stacks`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node'); - const stackNames = await stacksRes.json() as string[]; - - const stacks: SnapshotNodeData['stacks'] = []; - - for (const stackName of stackNames) { - const files: Array<{ filename: string; content: string }> = []; - try { - const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (composeRes.ok) { - const content = await composeRes.text(); - files.push({ filename: 'compose.yaml', content }); - } - } catch (e) { - console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message); - continue; - } - try { - const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (envRes.ok) { - const content = await envRes.text(); - files.push({ filename: '.env', content }); - } - } catch { - // No .env - skip - } - if (files.length > 0) { - stacks.push({ stackName, files }); - } - } - - return { nodeId: node.id, nodeName: node.name, stacks }; -} - // Create fleet snapshot app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; @@ -2119,6 +2033,7 @@ app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Respo const nodes = db.getNodes(); const username = req.user?.username || 'admin'; + const captureStart = Date.now(); const results = await Promise.allSettled( nodes.map(async (node) => { if (node.type === 'remote') { @@ -2175,6 +2090,12 @@ app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Respo } console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks'); + if (isDebugEnabled()) { + console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`); + for (const skip of skippedNodes) { + console.debug(`[Fleet:debug] Skipped node "${skip.nodeName}" (id=${skip.nodeId}): ${skip.reason}`); + } + } const snapshot = db.getSnapshot(snapshotId); res.status(201).json(snapshot); } catch (error) { @@ -2279,6 +2200,11 @@ app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request return; } + if (isDebugEnabled()) { + const fileNames = files.map(f => f.filename).join(', '); + console.debug(`[Fleet:debug] Restore: snapshot=${snapshotId}, node=${nodeId}, stack="${stackName}", files=[${fileNames}], redeploy=${redeploy}`); + } + const node = db.getNode(nodeId); if (!node) { res.status(404).json({ error: 'Target node no longer exists' }); @@ -2372,6 +2298,9 @@ app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: res.status(404).json({ error: 'Snapshot not found' }); return; } + if (isDebugEnabled()) { + console.debug(`[Fleet:debug] Deleting snapshot ${id} (${snapshot.node_count} node(s), ${snapshot.stack_count} stack(s))`); + } db.deleteSnapshot(id); console.log('[Fleet] Snapshot deleted:', id); res.json({ message: 'Snapshot deleted' }); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 84cffb5c..fb0792a6 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -9,6 +9,7 @@ import { ImageUpdateService } from './ImageUpdateService'; import type { ImageCheckResult } from './ImageUpdateService'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; @@ -253,9 +254,9 @@ export class SchedulerService { const results = await Promise.allSettled( nodes.map(async (node) => { if (node.type === 'remote') { - return this.captureRemoteNodeFiles(node); + return captureRemoteNodeFiles(node); } - return this.captureLocalNodeFiles(node); + return captureLocalNodeFiles(node); }) ); @@ -305,85 +306,13 @@ export class SchedulerService { db.insertSnapshotFiles(snapshotId, allFiles); } + if (isDebugEnabled()) { + console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}`); + } + return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`; } - private async captureLocalNodeFiles(node: { id: number; name: string }) { - const fsService = FileSystemService.getInstance(node.id); - const stackNames = await fsService.getStacks(); - const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = []; - - for (const stackName of stackNames) { - const files: Array<{ filename: string; content: string }> = []; - try { - const composeContent = await fsService.getStackContent(stackName); - files.push({ filename: 'compose.yaml', content: composeContent }); - } catch { - continue; - } - try { - const envContent = await fsService.getEnvContent(stackName); - files.push({ filename: '.env', content: envContent }); - } catch { - // No .env file - } - stacks.push({ stackName, files }); - } - - return { nodeId: node.id, nodeName: node.name, stacks }; - } - - private async captureRemoteNodeFiles(node: { id: number; name: string; api_url?: string; api_token?: string }) { - if (!node.api_url || !node.api_token) { - throw new Error('Remote node not configured'); - } - - const baseUrl = node.api_url.replace(/\/$/, ''); - const headers = { Authorization: `Bearer ${node.api_token}` }; - - const stacksRes = await fetch(`${baseUrl}/api/stacks`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node'); - const stackNames = await stacksRes.json() as string[]; - - const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = []; - - for (const stackName of stackNames) { - const files: Array<{ filename: string; content: string }> = []; - try { - const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (composeRes.ok) { - const content = await composeRes.text(); - files.push({ filename: 'compose.yaml', content }); - } - } catch { - continue; - } - try { - const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { - headers, - signal: AbortSignal.timeout(15000), - }); - if (envRes.ok) { - const content = await envRes.text(); - files.push({ filename: '.env', content }); - } - } catch { - // No .env - } - if (files.length > 0) { - stacks.push({ stackName, files }); - } - } - - return { nodeId: node.id, nodeName: node.name, stacks }; - } - private async executePrune(task: ScheduledTask): Promise { const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId(); if (task.node_id == null && isDebugEnabled()) { diff --git a/backend/src/utils/snapshot-capture.ts b/backend/src/utils/snapshot-capture.ts new file mode 100644 index 00000000..ba71a3d4 --- /dev/null +++ b/backend/src/utils/snapshot-capture.ts @@ -0,0 +1,123 @@ +/** + * Shared snapshot capture functions used by both the REST API (index.ts) + * and the SchedulerService for fleet-wide snapshot operations. + */ + +import { FileSystemService } from '../services/FileSystemService'; +import { isDebugEnabled } from './debug'; + +export interface SnapshotNodeData { + nodeId: number; + nodeName: string; + stacks: Array<{ + stackName: string; + files: Array<{ filename: string; content: string }>; + }>; +} + +/** Minimal node shape accepted by capture functions. */ +export interface CaptureNode { + id: number; + name: string; + api_url?: string; + api_token?: string; +} + +/** + * Read compose.yaml and .env files for every stack on a local node. + * Stacks whose compose file cannot be read are silently skipped. + */ +export async function captureLocalNodeFiles(node: CaptureNode): Promise { + const start = Date.now(); + const fsService = FileSystemService.getInstance(node.id); + const stackNames = await fsService.getStacks(); + const stacks: SnapshotNodeData['stacks'] = []; + + for (const stackName of stackNames) { + const files: Array<{ filename: string; content: string }> = []; + try { + const composeContent = await fsService.getStackContent(stackName); + files.push({ filename: 'compose.yaml', content: composeContent }); + } catch (e) { + console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message); + continue; + } + try { + const envContent = await fsService.getEnvContent(stackName); + files.push({ filename: '.env', content: envContent }); + } catch { + // No .env file - that's fine + } + stacks.push({ stackName, files }); + } + + if (isDebugEnabled()) { + const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0); + console.debug(`[Fleet:debug] Local capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`); + } + + return { nodeId: node.id, nodeName: node.name, stacks }; +} + +/** + * Fetch compose.yaml and .env files for every stack on a remote node + * via the Distributed API proxy. Stacks whose compose file cannot be + * fetched are silently skipped. + */ +export async function captureRemoteNodeFiles(node: CaptureNode): Promise { + if (!node.api_url || !node.api_token) { + throw new Error('Remote node not configured'); + } + + const start = Date.now(); + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + + const stacksRes = await fetch(`${baseUrl}/api/stacks`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node'); + const stackNames = await stacksRes.json() as string[]; + + const stacks: SnapshotNodeData['stacks'] = []; + + for (const stackName of stackNames) { + const files: Array<{ filename: string; content: string }> = []; + try { + const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (composeRes.ok) { + const content = await composeRes.text(); + files.push({ filename: 'compose.yaml', content }); + } + } catch (e) { + console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message); + continue; + } + try { + const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (envRes.ok) { + const content = await envRes.text(); + files.push({ filename: '.env', content }); + } + } catch { + // No .env - skip + } + if (files.length > 0) { + stacks.push({ stackName, files }); + } + } + + if (isDebugEnabled()) { + const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0); + console.debug(`[Fleet:debug] Remote capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`); + } + + return { nodeId: node.id, nodeName: node.name, stacks }; +} diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index f21437cb..e2c990a1 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -95,3 +95,28 @@ Admins can delete snapshots from the list view by clicking the trash icon on the ## Storage Snapshots are stored in Sencho's SQLite database. Compose files are typically small (under 10 KB each), so even hundreds of snapshots consume minimal disk space. For very large fleets, consider periodically deleting old snapshots to keep the database lean. + +## Troubleshooting + +### Snapshot shows skipped nodes + +If a remote node is offline, unreachable, or its API token has expired, the node is skipped during snapshot creation. The snapshot list shows a warning icon with a count of skipped nodes. Open the snapshot's detail view to see which nodes were skipped and the reason for each. + +Common causes: +- The remote Sencho instance is stopped or restarting +- The node's API URL or token was changed after it was added +- A firewall or network issue is blocking the connection between nodes + +To resolve, verify the remote node is running and reachable, then update the node's API URL and token in the Fleet settings if needed. Create a new snapshot after fixing the connectivity issue. + +### Restore fails with "Target node no longer exists" + +This error occurs when the node recorded in the snapshot has been removed from your fleet since the snapshot was taken. You cannot restore to a node that is no longer registered. Re-add the node first, then retry the restore. + +### Restore fails with "No files found for this stack" + +The stack you're trying to restore may not have been captured in the snapshot (for example, if the compose file was missing or unreadable at the time the snapshot was taken). Open the snapshot's detail view to verify which stacks and files are available. + +### Diagnostic logging + +If you need to investigate snapshot operations in detail, enable **Developer Mode** in **Settings > Developer**. This activates diagnostic logging for snapshot creation (per-node capture timing and file counts), restore operations, and scheduled snapshot execution. Diagnostic logs appear in the server's standard output with a `:debug` suffix. diff --git a/docs/images/fleet-backups/browse-snapshots.png b/docs/images/fleet-backups/browse-snapshots.png index 8e725d7d..3e884011 100644 Binary files a/docs/images/fleet-backups/browse-snapshots.png and b/docs/images/fleet-backups/browse-snapshots.png differ diff --git a/docs/images/fleet-backups/create-snapshot.png b/docs/images/fleet-backups/create-snapshot.png index bb40cef2..3acc26e0 100644 Binary files a/docs/images/fleet-backups/create-snapshot.png and b/docs/images/fleet-backups/create-snapshot.png differ diff --git a/docs/images/fleet-backups/restore-dialog.png b/docs/images/fleet-backups/restore-dialog.png index bb5c3639..4254072c 100644 Binary files a/docs/images/fleet-backups/restore-dialog.png and b/docs/images/fleet-backups/restore-dialog.png differ diff --git a/docs/images/fleet-backups/snapshot-detail.png b/docs/images/fleet-backups/snapshot-detail.png index 7a79dfdd..8ea4413a 100644 Binary files a/docs/images/fleet-backups/snapshot-detail.png and b/docs/images/fleet-backups/snapshot-detail.png differ diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index c134cfea..aea5e867 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, - Eye, ChevronDown, ChevronRight, Plus, Loader2, RotateCcw, + Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -17,6 +17,7 @@ import { 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'; @@ -59,6 +60,8 @@ interface SkippedNode { reason: string; } +const PAGE_SIZE = 10; + // --- Main Component --- export default function FleetSnapshots() { @@ -77,6 +80,12 @@ export default function FleetSnapshots() { const [previewFiles, setPreviewFiles] = useState>(new Set()); const [restoringStack, setRestoringStack] = useState(null); const [deletingId, setDeletingId] = useState(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 --- @@ -104,6 +113,7 @@ export default function FleetSnapshots() { const handleCreate = async () => { setCreating(true); + const loadingId = toast.loading('Creating fleet snapshot...'); try { const res = await apiFetch('/fleet/snapshots', { method: 'POST', @@ -123,6 +133,7 @@ export default function FleetSnapshots() { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { + toast.dismiss(loadingId); setCreating(false); } }; @@ -250,12 +261,12 @@ export default function FleetSnapshots() { className="gap-1.5 -ml-2" onClick={() => { setViewMode('list'); setSelectedSnapshot(null); }} > - + Back to Snapshots {loadingDetail ? ( -
+
@@ -267,7 +278,7 @@ export default function FleetSnapshots() { ) : selectedSnapshot ? ( <> {/* Header card */} -
+

{selectedSnapshot.description || 'Untitled Snapshot'}

@@ -276,10 +287,10 @@ export default function FleetSnapshots() { {new Date(selectedSnapshot.created_at).toLocaleString()}

- + {selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''} - + {selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
@@ -315,7 +326,7 @@ export default function FleetSnapshots() { {selectedSnapshot.nodes.map(node => { const nodeExpanded = expandedNodes.has(node.nodeId); return ( -
+
{/* Node header */} @@ -349,10 +360,10 @@ export default function FleetSnapshots() { : } - + {stack.stackName} - + {stack.files.length} file{stack.files.length !== 1 ? 's' : ''} @@ -367,21 +378,23 @@ export default function FleetSnapshots() {
- {file.filename} + {file.filename}
{showPreview && ( -
-                                                                                        {file.content}
-                                                                                    
+ +
+                                                                                            {file.content}
+                                                                                        
+
)}
); @@ -421,20 +434,35 @@ export default function FleetSnapshots() { {/* Header */}
- +

Fleet Snapshots

- {isAdmin && !showCreateForm && ( - - )} +
+ {needsPagination && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} + {isAdmin && !showCreateForm && ( + + )} +
{/* Create form */} {showCreateForm && ( -
+
+
{Array.from({ length: 3 }).map((_, i) => (
@@ -484,7 +512,7 @@ export default function FleetSnapshots() {
) : ( /* Snapshots table */ -
+
@@ -496,12 +524,12 @@ export default function FleetSnapshots() { - {snapshots.map(snapshot => { + {pagedSnapshots.map(snapshot => { const skipped = parseSkippedNodes(snapshot.skipped_nodes); const skippedNames = skipped.map(s => s.nodeName).join(', '); return ( - + {new Date(snapshot.created_at).toLocaleString()} @@ -511,7 +539,7 @@ export default function FleetSnapshots() { No description )} - + {snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''} {' · '} {snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''} @@ -523,7 +551,7 @@ export default function FleetSnapshots() { title={`Skipped: ${skippedNames}`} > - {skipped.length} + {skipped.length} ) : ( None @@ -537,7 +565,7 @@ export default function FleetSnapshots() { className="h-7 px-2 text-xs" onClick={() => handleViewDetail(snapshot)} > - + View {isAdmin && ( @@ -546,13 +574,13 @@ export default function FleetSnapshots() { @@ -598,9 +626,10 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: { onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise; }) { const [redeploy, setRedeploy] = useState(false); + const [open, setOpen] = useState(false); return ( - + @@ -640,13 +669,19 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: { Cancel - onRestore(nodeId, stackName, redeploy)} + onClick={async () => { + try { + await onRestore(nodeId, stackName, redeploy); + } finally { + setOpen(false); + } + }} > {restoring && } Restore - +