mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user