Files
Christopher Mayor 0910c966a5 feat: migrate from static config to database and add authentication
- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma)

- Implemented JWT authentication and Login UI

- Added dynamic API routes for hosts, topology, and settings

- Updated UI components to fetch and manage state dynamically

- Added Settings interface for managing hosts and topology nodes
2026-02-25 14:07:11 -08:00

168 lines
4.2 KiB
TypeScript

/**
* SSH Stats API Endpoint
*
* GET /api/stats/:host/:container - Get real-time docker stats for a container
*/
import { Router } from 'express';
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
import { StatsResponse } from '../types';
const router = Router();
interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: Buffer;
}
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeoutId = setTimeout(() => {
conn.end();
reject(new Error('Connection timeout'));
}, timeout);
conn.on('ready', () => {
clearTimeout(timeoutId);
resolve(conn);
});
conn.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
readyTimeout: timeout,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
async function getContainerStats(
hostIp: string,
containerName: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<StatsResponse> {
// Load SSH key
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = readFileSync(keyPath);
} catch {
// If key file doesn't exist, try without it
}
const sshConfig: SSHConnectionConfig = {
host: hostIp,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
// Run docker stats with JSON format
const dockerStatsCmd = `docker stats ${containerName} --no-stream --format '{"cpu":"{{.CPUPerc}}","mem":"{{.MemPerc}}","net":"{{.NetIO}}"}'`;
const output = await execSSH(conn, dockerStatsCmd);
conn.end();
// Parse the output
try {
const parsed = JSON.parse(output.trim());
// Parse CPU (remove % and convert to number)
const cpu = parsed.cpu ? parseFloat(parsed.cpu.replace('%', '')) : 0;
// Parse Memory (remove % and convert to number)
const memory = parsed.mem ? parseFloat(parsed.mem.replace('%', '')) : 0;
// Parse Network I/O (format: "1.2MB / 800kB" -> rx: "1.2MB", tx: "800kB")
const netParts = parsed.net ? parsed.net.split(' / ') : ['0B', '0B'];
const rx = netParts[0]?.trim() || '0B';
const tx = netParts[1]?.trim() || '0B';
return {
cpu,
memory,
network: { rx, tx },
};
} catch (parseError) {
return {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: 'Failed to parse stats output',
};
}
}
// GET /api/stats/:host/:container
router.get('/stats/:host/:container', async (req, res) => {
try {
const { host, container } = req.params;
// Find host config by name
const hosts = await getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
const response: StatsResponse = {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: `Host '${host}' not found`,
};
return res.status(404).json(response);
}
const stats = await getContainerStats(
hostConfig.ip,
container,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(stats);
} catch (error: any) {
const response: StatsResponse = {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: error.message || 'Failed to get stats',
};
res.status(500).json(response);
}
});
export default router;