mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
fix(fleet): add auth middleware, input validation, and design system compliance (#536)
Add authMiddleware to all 13 fleet endpoints that were previously accessible without authentication. Add NaN validation for parseInt params, stackName validation on snapshot restore, and description length cap on snapshot creation. Clean up updateTracker entries on node deletion to prevent memory leaks. Replace hardcoded colors with design system tokens, swap Select for Combobox, replace overflow-y-auto with ScrollArea, fix card styling (shadow-card-bevel, border tokens). Fix stale container data by always refetching on stack expand with a loading guard against concurrent requests. Add operational logging for state-changing fleet operations and diagnostic logging gated behind Developer Mode. Add 20 fleet tests covering auth enforcement, input validation, tier gating, and snapshot CRUD lifecycle.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+61
-21
@@ -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<void> => {
|
||||
app.get('/api/fleet/overview', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
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<FleetNodeOverview> => {
|
||||
@@ -1416,6 +1419,10 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise<voi
|
||||
};
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
const online = overview.filter(n => 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<voi
|
||||
});
|
||||
|
||||
// Paid-gated: detailed stack info per node
|
||||
app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
|
||||
app.get('/api/fleet/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<v
|
||||
const gatewayVersion = getSenchoVersion();
|
||||
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
||||
|
||||
console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered');
|
||||
|
||||
// Filter to eligible candidates, then trigger all in parallel
|
||||
const candidates = nodes.filter(node => {
|
||||
if (node.type === 'local') return false;
|
||||
@@ -1793,10 +1814,11 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<v
|
||||
});
|
||||
|
||||
// Clear update tracker entry for a specific node (dismiss or before retry)
|
||||
app.delete('/api/fleet/nodes/:nodeId/update-status', async (req: Request, res: Response): Promise<void> => {
|
||||
app.delete('/api/fleet/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.delete('/api/fleet/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<FleetNodeOverview> {
|
||||
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<SnapshotNodeData> {
|
||||
}
|
||||
|
||||
// Create fleet snapshot
|
||||
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<vo
|
||||
db.insertSnapshotFiles(snapshotId, allFiles);
|
||||
}
|
||||
|
||||
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
|
||||
const snapshot = db.getSnapshot(snapshotId);
|
||||
res.status(201).json(snapshot);
|
||||
} catch (error) {
|
||||
@@ -2107,7 +2135,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<vo
|
||||
});
|
||||
|
||||
// List fleet snapshots
|
||||
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
app.get('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
@@ -2116,6 +2144,7 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<voi
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshots = db.getSnapshots(limit, offset);
|
||||
const total = db.getSnapshotCount();
|
||||
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshots list: limit=', limit, 'offset=', offset, 'total=', total);
|
||||
res.json({ snapshots, total });
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] List error:', error);
|
||||
@@ -2124,11 +2153,12 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<voi
|
||||
});
|
||||
|
||||
// Get snapshot detail
|
||||
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
app.get('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 94 KiB |
@@ -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 (
|
||||
<div className={`rounded-lg border bg-card p-4 ${alert ? 'border-red-500/30 bg-red-500/5' : ''}`}>
|
||||
<div className={`rounded-lg border bg-card text-card-foreground shadow-card-bevel p-4 transition-colors ${alert ? 'border-destructive/30 bg-destructive/5' : 'border-card-border border-t-card-border-top hover:border-t-card-border-hover'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${alert ? 'text-red-500' : 'text-stat-icon'}`} />
|
||||
<Icon className={`w-4 h-4 ${alert ? 'text-destructive' : 'text-stat-icon'}`} />
|
||||
<span className="text-xs text-stat-title">{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-medium tabular-nums tracking-tight ${alert ? 'text-destructive/70' : 'text-stat-value'}`}>{value}</div>
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
|
||||
@@ -203,7 +202,7 @@ function ContainerRow({ container, nodeId, onNavigate }: {
|
||||
onClick={() => onNavigate(nodeId)}
|
||||
title="Open in editor"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className={`rounded-xl border bg-card text-card-foreground transition-all ${isOnline ? '' : 'opacity-60'}`}>
|
||||
<div className={`rounded-xl border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover ${isOnline ? '' : 'opacity-60'}`}>
|
||||
{/* Card Header */}
|
||||
<div className="p-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -548,7 +550,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
|
||||
</span>
|
||||
<span className="font-medium">{node.systemStats.cpu.usage}%</span>
|
||||
</div>
|
||||
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
|
||||
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
@@ -557,7 +559,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}</span>
|
||||
</div>
|
||||
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
|
||||
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
|
||||
</div>
|
||||
{node.systemStats.disk && (
|
||||
<div>
|
||||
@@ -567,7 +569,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}</span>
|
||||
</div>
|
||||
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} />
|
||||
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1069,19 +1071,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</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>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'cpu', label: 'CPU Usage' },
|
||||
{ value: 'memory', label: 'Memory Usage' },
|
||||
{ value: 'containers', label: 'Containers' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
]}
|
||||
value={prefs.sortBy}
|
||||
onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}
|
||||
placeholder="Sort by..."
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1127,7 +1128,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
||||
variant={prefs.filterCritical ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
||||
@@ -1207,14 +1208,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<PaidGate featureName="Fleet Management">
|
||||
{/* Preview of what paid tier 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 className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel 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 className="h-9 rounded-md border border-card-border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border border-card-border bg-card w-[150px]" />
|
||||
</div>
|
||||
</PaidGate>
|
||||
</div>
|
||||
@@ -1263,25 +1264,25 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<>
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<Loader2 className="w-3 h-3 text-info" strokeWidth={1.5} /> Updating
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
|
||||
@@ -1317,9 +1318,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
|
||||
{/* Node list */}
|
||||
<div className="flex-1 overflow-y-auto space-y-1 min-h-0 max-h-[40vh] -mx-1 px-1">
|
||||
<ScrollArea className="flex-1 min-h-0 max-h-[40vh] -mx-1 px-1">
|
||||
<div className="space-y-1">
|
||||
{filtered.map(s => (
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border bg-card px-3 py-2">
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2">
|
||||
{/* Node name */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
||||
@@ -1384,7 +1386,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
No nodes match “{modalSearch}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||
|
||||
Reference in New Issue
Block a user