Files
sencho/backend/src/utils/snapshot-capture.ts
T
Anso 809bf76c20 fix(fleet): harden fleet snapshots with DRY capture, audit fixes, and design compliance (#555)
Extract duplicated snapshot capture functions from index.ts and
SchedulerService.ts into a shared module (snapshot-capture.ts). Fix
audit log route patterns that used singular 'snapshot' instead of
plural 'snapshots'. Apply design system to FleetSnapshots component
(card styling, strokeWidth, font-mono, tabular-nums, ScrollArea).
Add safePage pagination, loading toast for creation, and fix the
restore dialog race condition with a controlled AlertDialog. Add
diagnostic logging gated behind Developer Mode. Add tests for
restore endpoint, admin role enforcement, and edge cases. Update
docs with troubleshooting section and refresh screenshots.
2026-04-13 13:26:55 -04:00

124 lines
4.1 KiB
TypeScript

/**
* Shared snapshot capture functions used by both the REST API (index.ts)
* and the SchedulerService for fleet-wide snapshot operations.
*/
import { FileSystemService } from '../services/FileSystemService';
import { isDebugEnabled } from './debug';
export interface SnapshotNodeData {
nodeId: number;
nodeName: string;
stacks: Array<{
stackName: string;
files: Array<{ filename: string; content: string }>;
}>;
}
/** Minimal node shape accepted by capture functions. */
export interface CaptureNode {
id: number;
name: string;
api_url?: string;
api_token?: string;
}
/**
* Read compose.yaml and .env files for every stack on a local node.
* Stacks whose compose file cannot be read are silently skipped.
*/
export async function captureLocalNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
const start = Date.now();
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch (e) {
console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message);
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file - that's fine
}
stacks.push({ stackName, files });
}
if (isDebugEnabled()) {
const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0);
console.debug(`[Fleet:debug] Local capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
/**
* Fetch compose.yaml and .env files for every stack on a remote node
* via the Distributed API proxy. Stacks whose compose file cannot be
* fetched are silently skipped.
*/
export async function captureRemoteNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const start = Date.now();
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch (e) {
console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message);
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env - skip
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
if (isDebugEnabled()) {
const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0);
console.debug(`[Fleet:debug] Remote capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks };
}