diff --git a/backend/src/__tests__/fleet.test.ts b/backend/src/__tests__/fleet.test.ts new file mode 100644 index 00000000..73eacf1a --- /dev/null +++ b/backend/src/__tests__/fleet.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for fleet management API endpoints. + * Covers auth enforcement, input validation, overview, snapshot CRUD, and tier gating. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +function mockTier(tier: 'paid' | 'community') { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +} + +// ─── Auth Enforcement ─── + +describe('Fleet endpoints require authentication', () => { + it('GET /api/fleet/overview returns 401 without auth', async () => { + const res = await request(app).get('/api/fleet/overview'); + expect(res.status).toBe(401); + }); + + it('GET /api/fleet/update-status returns 401 without auth', async () => { + const res = await request(app).get('/api/fleet/update-status'); + expect(res.status).toBe(401); + }); + + it('POST /api/fleet/snapshots returns 401 without auth', async () => { + const res = await request(app).post('/api/fleet/snapshots').send({ description: 'test' }); + expect(res.status).toBe(401); + }); + + it('GET /api/fleet/snapshots returns 401 without auth', async () => { + const res = await request(app).get('/api/fleet/snapshots'); + expect(res.status).toBe(401); + }); + + it('DELETE /api/fleet/snapshots/1 returns 401 without auth', async () => { + const res = await request(app).delete('/api/fleet/snapshots/1'); + expect(res.status).toBe(401); + }); + + it('POST /api/fleet/nodes/1/update returns 401 without auth', async () => { + const res = await request(app).post('/api/fleet/nodes/1/update'); + expect(res.status).toBe(401); + }); +}); + +// ─── Input Validation ─── + +describe('Fleet input validation', () => { + afterEach(() => vi.restoreAllMocks()); + + it('rejects NaN nodeId on GET /api/fleet/node/:nodeId/stacks', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/node/abc/stacks') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid node id/i); + }); + + it('rejects NaN nodeId on GET /api/fleet/node/:nodeId/stacks/:stackName/containers', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/node/xyz/stacks/mystack/containers') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid node id/i); + }); + + it('rejects invalid stackName on containers endpoint', async () => { + mockTier('paid'); + // Stack name with characters that fail the alphanumeric+dash+underscore regex + const res = await request(app) + .get('/api/fleet/node/1/stacks/bad%20stack%21/containers') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid stack name/i); + }); + + it('rejects NaN snapshot ID on GET /api/fleet/snapshots/:id', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/snapshots/notanumber') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid snapshot id/i); + }); + + it('rejects oversized snapshot description', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', authHeader) + .send({ description: 'x'.repeat(501) }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/500 characters/i); + }); +}); + +// ─── Fleet Overview ─── + +describe('GET /api/fleet/overview', () => { + it('returns 200 with an array', async () => { + const res = await request(app) + .get('/api/fleet/overview') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('includes the local node', async () => { + const res = await request(app) + .get('/api/fleet/overview') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + const local = res.body.find((n: { type: string }) => n.type === 'local'); + expect(local).toBeDefined(); + expect(local.name).toBeTruthy(); + }); +}); + +// ─── Tier Gating ─── + +describe('Fleet tier gating', () => { + afterEach(() => vi.restoreAllMocks()); + + it('GET /api/fleet/update-status returns 403 on free tier', async () => { + mockTier('community'); + const res = await request(app) + .get('/api/fleet/update-status') + .set('Authorization', authHeader); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('GET /api/fleet/snapshots returns 403 on free tier', async () => { + mockTier('community'); + const res = await request(app) + .get('/api/fleet/snapshots') + .set('Authorization', authHeader); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); +}); + +// ─── Snapshot CRUD ─── + +describe('Fleet snapshot lifecycle', () => { + afterEach(() => vi.restoreAllMocks()); + + let snapshotId: number; + + it('creates a snapshot (POST /api/fleet/snapshots)', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/snapshots') + .set('Authorization', authHeader) + .send({ description: 'Test snapshot' }); + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body.description).toBe('Test snapshot'); + snapshotId = res.body.id; + }); + + it('lists snapshots (GET /api/fleet/snapshots)', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/snapshots') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('snapshots'); + expect(res.body).toHaveProperty('total'); + expect(res.body.total).toBeGreaterThanOrEqual(1); + }); + + it('gets snapshot detail (GET /api/fleet/snapshots/:id)', async () => { + mockTier('paid'); + const res = await request(app) + .get(`/api/fleet/snapshots/${snapshotId}`) + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.id).toBe(snapshotId); + expect(res.body).toHaveProperty('nodes'); + }); + + it('returns 404 for missing snapshot', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/fleet/snapshots/99999') + .set('Authorization', authHeader); + expect(res.status).toBe(404); + }); + + it('deletes a snapshot (DELETE /api/fleet/snapshots/:id)', async () => { + mockTier('paid'); + const res = await request(app) + .delete(`/api/fleet/snapshots/${snapshotId}`) + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/deleted/i); + + // Verify it's gone + const check = await request(app) + .get(`/api/fleet/snapshots/${snapshotId}`) + .set('Authorization', authHeader); + expect(check.status).toBe(404); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index f31aff73..ba5b26f4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import helmet from 'helmet'; import WebSocket, { WebSocketServer } from 'ws'; import jwt from 'jsonwebtoken'; import DockerController, { globalDockerNetwork, type CreateNetworkOptions, type NetworkDriver } from './services/DockerController'; +import type Dockerode from 'dockerode'; import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; import bcrypt from 'bcrypt'; @@ -1388,10 +1389,12 @@ interface FleetNodeOverview { stacks: string[] | null; } -app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise => { +app.get('/api/fleet/overview', authMiddleware, async (_req: Request, res: Response): Promise => { try { + const debug = isDebugEnabled(); const db = DatabaseService.getInstance(); const nodes = db.getNodes(); + if (debug) console.debug('[Fleet:debug] Overview requested, fetching', nodes.length, 'nodes'); const results = await Promise.allSettled( nodes.map(async (node): Promise => { @@ -1416,6 +1419,10 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise n.status === 'online').length; + console.debug('[Fleet:debug] Overview complete:', online, 'online,', overview.length - online, 'offline'); + } res.json(overview); } catch (error) { console.error('[Fleet] Overview error:', error); @@ -1424,11 +1431,12 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise => { +app.get('/api/fleet/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); + if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; } const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); @@ -1449,11 +1457,13 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P return; } const stacks = await response.json(); + if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, Array.isArray(stacks) ? stacks.length : 0, 'stacks'); res.json(stacks); return; } const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, stacks.length, 'stacks'); res.json(stacks); } catch (error) { console.error('[Fleet] Node stacks error:', error); @@ -1462,11 +1472,12 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P }); // Paid-gated: container details for a specific stack on a specific node -app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Request, res: Response): Promise => { +app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); + if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; } const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -1498,6 +1509,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ const dockerController = DockerController.getInstance(nodeId); const containers = await dockerController.getContainersByStack(stackName); + if (isDebugEnabled()) console.debug('[Fleet:debug] Stack containers:', nodeId, stackName, containers.length, 'containers'); res.json(containers); } catch (error) { console.error('[Fleet] Node stack containers error:', error); @@ -1506,7 +1518,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ }); // Fleet Update Status — returns version comparison and active update status for all nodes -app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promise => { +app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: Response): Promise => { if (!requirePaid(_req, res)) return; try { const db = DatabaseService.getInstance(); @@ -1642,6 +1654,10 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis }; }); + if (isDebugEnabled()) { + const trackerStates = Array.from(updateTracker.entries()).map(([nid, t]) => `${nid}:${t.status}`); + console.debug('[Fleet:debug] Update status:', nodeStatuses.length, 'nodes, trackers:', trackerStates.join(', ') || 'none'); + } res.json({ nodes: nodeStatuses }); } catch (error) { console.error('[Fleet] Update status error:', error); @@ -1650,10 +1666,11 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis }); // Trigger update on a specific node -app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): Promise => { +app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); + if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; } const db = DatabaseService.getInstance(); const node = db.getNode(nodeId); if (!node) { @@ -1675,6 +1692,8 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): updateTracker.delete(nodeId); } + console.log('[Fleet] Update triggered for node', node.name, node.type); + if (node.type === 'local') { if (!SelfUpdateService.getInstance().isAvailable()) { res.status(503).json({ error: 'Self-update unavailable on the local node.' }); @@ -1734,7 +1753,7 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): }); // Trigger update on all outdated nodes -app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise => { +app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const db = DatabaseService.getInstance(); @@ -1742,6 +1761,8 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise { if (node.type === 'local') return false; @@ -1793,10 +1814,11 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise => { +app.delete('/api/fleet/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); + if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; } const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); @@ -1811,7 +1833,7 @@ app.delete('/api/fleet/nodes/:nodeId/update-status', async (req: Request, res: R }); // Clear all terminal (timed-out, failed, completed) tracker entries at once -app.delete('/api/fleet/update-status', async (req: Request, res: Response): Promise => { +app.delete('/api/fleet/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; // Pre-fetch fresh latest version so the next GET has up-to-date data if (req.query.recheck === 'true') { @@ -1836,18 +1858,19 @@ async function fetchLocalNodeOverview(node: Node): Promise { si.fsSize(), ]); - const isManagedByComposeDir = (c: any): boolean => { + const isManagedByComposeDir = (c: Dockerode.ContainerInfo): boolean => { const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; if (!workingDir) return false; const resolved = path.resolve(workingDir); return resolved === composeDir || resolved.startsWith(composeDir + path.sep); }; - const active = allContainers.filter((c: any) => c.State === 'running').length; - const exited = allContainers.filter((c: any) => c.State === 'exited').length; - const total = allContainers.length; - const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; - const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; + const containers = allContainers as Dockerode.ContainerInfo[]; + const active = containers.filter(c => c.State === 'running').length; + const exited = containers.filter(c => c.State === 'exited').length; + const total = containers.length; + const managed = containers.filter(c => c.State === 'running' && isManagedByComposeDir(c)).length; + const unmanaged = containers.filter(c => c.State === 'running' && !isManagedByComposeDir(c)).length; const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; @@ -2033,12 +2056,16 @@ async function captureRemoteNodeFiles(node: Node): Promise { } // Create fleet snapshot -app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { +app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { const { description = '' } = req.body; + if (typeof description === 'string' && description.length > 500) { + res.status(400).json({ error: 'Description must be 500 characters or less' }); + return; + } const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const username = req.user?.username || 'admin'; @@ -2098,6 +2125,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { +app.get('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { @@ -2116,6 +2144,7 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { +app.get('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; } const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(id); if (!snapshot) { @@ -2160,6 +2190,7 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise })), })); + if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files'); res.json({ ...snapshot, nodes }); } catch (error) { console.error('[Fleet Snapshot] Detail error:', error); @@ -2168,18 +2199,23 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise }); // Restore a stack from snapshot -app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise => { +app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { const snapshotId = parseInt(req.params.id as string, 10); + if (isNaN(snapshotId)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; } const { nodeId, stackName, redeploy = false } = req.body; if (!nodeId || !stackName) { res.status(400).json({ error: 'nodeId and stackName are required' }); return; } + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(snapshotId); @@ -2265,6 +2301,7 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response) } } + console.log('[Fleet] Snapshot restore:', snapshotId, 'node=', nodeId, 'stack=', stackName); res.json({ message: 'Stack restored successfully', redeployed: redeploy }); } catch (error) { console.error('[Fleet Snapshot] Restore error:', error); @@ -2273,12 +2310,13 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response) }); // Delete snapshot -app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise => { +app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; } const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(id); if (!snapshot) { @@ -2286,6 +2324,7 @@ app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Prom return; } db.deleteSnapshot(id); + console.log('[Fleet] Snapshot deleted:', id); res.json({ message: 'Snapshot deleted' }); } catch (error) { console.error('[Fleet Snapshot] Delete error:', error); @@ -6145,10 +6184,11 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => { DatabaseService.getInstance().deleteNode(id); NodeRegistry.getInstance().evictConnection(id); CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`); + updateTracker.delete(id); res.json({ success: true }); - } catch (error: any) { + } catch (error: unknown) { console.error('Failed to delete node:', error); - res.status(500).json({ error: error.message || 'Failed to delete node' }); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to delete node' }); } }); diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 291d3e1f..c6784d39 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -118,6 +118,8 @@ Click a stack name to expand it further and see individual containers with: - Image name (e.g. `linuxserver/plex:latest`) - Uptime (e.g. "Up 4 days") +Container data is fetched fresh each time you expand a stack, so you always see the current state. + Hover over any container row to reveal an **Open in editor** button that navigates you directly to that stack's editor on the corresponding node. diff --git a/docs/images/fleet-view/fleet-drill-down.png b/docs/images/fleet-view/fleet-drill-down.png index 66be35ed..bdab5622 100644 Binary files a/docs/images/fleet-view/fleet-drill-down.png and b/docs/images/fleet-view/fleet-drill-down.png differ diff --git a/docs/images/fleet-view/fleet-node-updates.png b/docs/images/fleet-view/fleet-node-updates.png index 07a2ae52..e4024a1a 100644 Binary files a/docs/images/fleet-view/fleet-node-updates.png and b/docs/images/fleet-view/fleet-node-updates.png differ diff --git a/docs/images/fleet-view/fleet-overview.png b/docs/images/fleet-view/fleet-overview.png index 754d6799..ee122a91 100644 Binary files a/docs/images/fleet-view/fleet-overview.png and b/docs/images/fleet-view/fleet-overview.png differ diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 6eef700f..472bd264 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -9,9 +9,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Input } from '@/components/ui/input'; -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; +import { Combobox } from '@/components/ui/combobox'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, @@ -157,9 +156,9 @@ function StatCard({ icon: Icon, label, value, sub, alert }: { alert?: boolean; }) { return ( -
+
- + {label}
{value}
@@ -179,7 +178,7 @@ function ContainerRow({ container, nodeId, onNavigate }: { const status = container.Status ?? ''; const stateColor = state === 'running' ? 'bg-success' : - state === 'restarting' ? 'bg-warning' : 'bg-red-500'; + state === 'restarting' ? 'bg-warning' : 'bg-destructive'; return (
@@ -203,7 +202,7 @@ function ContainerRow({ container, nodeId, onNavigate }: { onClick={() => onNavigate(nodeId)} title="Open in editor" > - +
); @@ -220,10 +219,11 @@ function StackSection({ stackName, nodeId, onNavigate, labelMap }: { const [loading, setLoading] = useState(false); const handleExpand = async () => { + if (loading) return; const next = !expanded; setExpanded(next); - if (next && containers === null) { + if (next) { setLoading(true); try { const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true }); @@ -232,7 +232,8 @@ function StackSection({ stackName, nodeId, onNavigate, labelMap }: { } else { toast.error('Failed to load containers for ' + stackName); } - } catch { + } catch (error) { + console.error('Failed to load containers for', stackName, error); toast.error('Failed to load containers for ' + stackName); } finally { setLoading(false); @@ -462,7 +463,8 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating } else { toast.error('Failed to load stacks for ' + node.name); } - } catch { + } catch (error) { + console.error('Failed to load stacks for', node.name, error); toast.error('Failed to load stacks for ' + node.name); } finally { setLoadingStacks(false); @@ -471,7 +473,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating }; return ( -
+
{/* Card Header */}
@@ -548,7 +550,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating {node.systemStats.cpu.usage}%
- 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} /> + 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
@@ -557,7 +559,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}
- 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} /> + 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
{node.systemStats.disk && (
@@ -567,7 +569,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}
- 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} /> + 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
)}
@@ -1069,19 +1071,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Sort */} - + updatePrefs({ sortBy: v as SortField })} + placeholder="Sort by..." + />