feat: integrate all 10 skills into homelab-topology
- Added api-security-hardening (helmet, rate limits) - Added nodejs-backend-patterns (error handling) - Added observability-monitoring (pino logging) - Added websocket-engineer (socket.io real-time updates) - Added docker (Multi-stage build, compose) - Added vitest (testing configuration and store tests) - Added data-visualizer (MetricsBar and HostChart) - Added infrastructure-monitoring/proxmox-admin/network-engineer types - Fixed UI accessibility and styling - Cleaned up node_modules tracking
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"ip": "192.168.50.61",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
},
|
||||
{
|
||||
"name": "grizzley",
|
||||
"ip": "192.168.50.84",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
},
|
||||
{
|
||||
"name": "truenas",
|
||||
"ip": "192.168.50.12",
|
||||
"sshUser": "root",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
},
|
||||
{
|
||||
"name": "proxmox",
|
||||
"ip": "192.168.50.11",
|
||||
"sshUser": "root",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
},
|
||||
{
|
||||
"name": "ice",
|
||||
"ip": "192.168.50.197",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
},
|
||||
{
|
||||
"name": "panda",
|
||||
"ip": "192.168.50.196",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { homedir } from 'os';
|
||||
import { HostConfig } from './types';
|
||||
|
||||
const CONFIG_FILE = path.join(__dirname, 'config.json');
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const CONFIG_FILE = path.join(__dirname, 'hosts.json');
|
||||
|
||||
function parseEnvHosts(): HostConfig[] {
|
||||
const hostsEnv = process.env.SSH_HOSTS;
|
||||
@@ -28,24 +32,32 @@ function parseEnvHosts(): HostConfig[] {
|
||||
}
|
||||
|
||||
function parseJsonConfig(): HostConfig[] {
|
||||
if (!fs.existsSync(CONFIG_FILE)) return [];
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
console.error('Config file not found:', CONFIG_FILE);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (!data.hosts || !Array.isArray(data.hosts)) {
|
||||
console.error('No hosts array in config');
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.hosts.map((h: Partial<HostConfig>) => ({
|
||||
const hosts = data.hosts.map((h: Partial<HostConfig>) => ({
|
||||
name: h.name || '',
|
||||
ip: h.ip || '',
|
||||
sshUser: h.sshUser || 'bear',
|
||||
sshKeyPath: h.sshKeyPath,
|
||||
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
|
||||
sshPort: h.sshPort || 22,
|
||||
})).filter((h: HostConfig) => h.name && h.ip);
|
||||
} catch {
|
||||
|
||||
console.error('Loaded hosts:', JSON.stringify(hosts));
|
||||
return hosts;
|
||||
} catch (e: any) {
|
||||
console.error('Config parse error:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
36
server/errors.ts
Normal file
36
server/errors.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Custom Error Classes for Homelab Topology API
|
||||
*
|
||||
* Structured error hierarchy following nodejs-backend-patterns skill.
|
||||
* All operational errors extend AppError with appropriate HTTP status codes.
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public message: string,
|
||||
public statusCode: number = 500,
|
||||
public isOperational: boolean = true
|
||||
) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, AppError.prototype);
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string = 'Resource not found') {
|
||||
super(message, 404);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, public errors?: Array<{ field: string; message: string }>) {
|
||||
super(message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends AppError {
|
||||
constructor(message: string = 'Service temporarily unavailable') {
|
||||
super(message, 503);
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,4 @@
|
||||
"hostType": "docker-host"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,40 +1,107 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { createServer } from 'http';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import discoverRouter from './routes/discover';
|
||||
import configRouter from './routes/config';
|
||||
import statsRouter from './routes/stats';
|
||||
import filesRouter from './routes/files';
|
||||
import terminalRouter from './routes/terminal';
|
||||
import { getHostConfigs } from './config';
|
||||
import { requestLogger, logger } from './middleware/requestLogger';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const PORT = 3001;
|
||||
|
||||
// CORS middleware for frontend communication
|
||||
// --- Socket.IO setup (websocket-engineer skill) ---
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
},
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 10000,
|
||||
});
|
||||
|
||||
io.on('connection', (socket: Socket) => {
|
||||
logger.info({ socketId: socket.id }, 'Client connected via WebSocket');
|
||||
|
||||
socket.on('disconnect', (reason: string) => {
|
||||
logger.info({ socketId: socket.id, reason }, 'Client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
// Export io so routes can emit events
|
||||
export { io };
|
||||
|
||||
// --- Security middleware (api-security-hardening skill) ---
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Disable CSP for dev — configure per-environment in production
|
||||
}));
|
||||
|
||||
// CORS — restrict to configured origins
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:3000',
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
// Rate limiting — general API
|
||||
app.use('/api/', rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200,
|
||||
message: { status: 'error', message: 'Too many requests, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
}));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
// Stricter rate limiting for discovery (expensive operation)
|
||||
app.use('/api/discover', rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1 minute
|
||||
max: 10,
|
||||
message: { status: 'error', message: 'Discovery rate limited — max 10 per minute' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
}));
|
||||
|
||||
// --- Body parsing ---
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// --- Observability (observability-monitoring skill) ---
|
||||
app.use(requestLogger);
|
||||
|
||||
// --- Health check ---
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
connections: io.engine.clientsCount,
|
||||
});
|
||||
});
|
||||
|
||||
// Debug endpoint to check config
|
||||
app.get('/api/debug-config', (req, res) => {
|
||||
const hosts = getHostConfigs();
|
||||
res.json({ hosts });
|
||||
});
|
||||
// --- Debug endpoint (dev only) ---
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.get('/api/debug-config', (_req, res) => {
|
||||
const hosts = getHostConfigs();
|
||||
res.json({ hosts });
|
||||
});
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
app.use('/api', discoverRouter);
|
||||
app.use('/api', configRouter);
|
||||
app.use('/api', statsRouter);
|
||||
app.use('/api', filesRouter);
|
||||
app.use('/api', terminalRouter);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
// --- Global error handler (must be last) ---
|
||||
app.use(errorHandler);
|
||||
|
||||
// --- Start server ---
|
||||
httpServer.listen(PORT, () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
42
server/middleware/errorHandler.ts
Normal file
42
server/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppError, ValidationError } from '../errors';
|
||||
import { logger } from './requestLogger';
|
||||
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Catches all errors, logs unexpected ones with Pino, and returns safe JSON responses.
|
||||
* Must be registered AFTER all routes.
|
||||
*/
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.statusCode).json({
|
||||
status: 'error',
|
||||
message: err.message,
|
||||
...(err instanceof ValidationError && err.errors ? { errors: err.errors } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Log unexpected errors
|
||||
logger.error({
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Don't leak error details in production
|
||||
const message =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message;
|
||||
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message,
|
||||
});
|
||||
};
|
||||
42
server/middleware/requestLogger.ts
Normal file
42
server/middleware/requestLogger.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import pino from 'pino';
|
||||
|
||||
/**
|
||||
* Pino structured logger.
|
||||
* - Development: colorized, human-readable output
|
||||
* - Production: JSON for log aggregation
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
...(process.env.NODE_ENV !== 'production' && {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request logging middleware.
|
||||
* Logs method, url, status, and duration for every request.
|
||||
*/
|
||||
export const requestLogger = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.info({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
49
server/routes/AGENTS.md
Normal file
49
server/routes/AGENTS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Server Routes (API Endpoints)
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
**Location:** server/routes/
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Express router modules exposing REST API endpoints for the homelab topology.
|
||||
|
||||
## ENDPOINTS
|
||||
|
||||
| File | Route | Method | Purpose |
|
||||
|------|-------|--------|---------|
|
||||
| discover.ts | /api/discover | POST | Run SSH discovery on specified hosts |
|
||||
| config.ts | /api/config | GET/PUT | Get or update configuration |
|
||||
| stats.ts | /api/stats | GET | Retrieve statistics |
|
||||
| files.ts | /api/files | GET | Get file topology |
|
||||
|
||||
## ADDING A NEW ENDPOINT
|
||||
|
||||
1. Create `server/routes/{name}.ts`:
|
||||
```typescript
|
||||
import { Router } from 'express';
|
||||
const router = Router();
|
||||
|
||||
router.get('/{name}', (req, res) => {
|
||||
// implementation
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
2. Import and mount in `server/index.ts`:
|
||||
```typescript
|
||||
import newRouter from './routes/{name}';
|
||||
app.use('/api', newRouter);
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- All routes prefixed with `/api` (mounted in index.ts)
|
||||
- Return JSON on success: `{ data: ... }`
|
||||
- On error: `{ error: string }`
|
||||
- Use async/await for async operations
|
||||
|
||||
## NOTES
|
||||
|
||||
- discover.ts: Main endpoint - accepts host list, returns topology data
|
||||
- CORS is configured at server/index.ts level, not per-route
|
||||
215
server/routes/config.ts
Normal file
215
server/routes/config.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* SSH Config API Endpoint
|
||||
*
|
||||
* GET /api/config/:host/:container - Get docker-compose config for a specific container
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Client } from 'ssh2';
|
||||
import { readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { getHostConfigs } from '../config';
|
||||
import { ConfigResponse } 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(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Find docker-compose.yml files on the remote host
|
||||
async function findDockerComposeFiles(conn: Client): Promise<string[]> {
|
||||
const command = `find /home -name "docker-compose.yml" 2>/dev/null | head -10`;
|
||||
const output = await execSSH(conn, command);
|
||||
|
||||
const files = output
|
||||
.split('\n')
|
||||
.map(f => f.trim())
|
||||
.filter(f => f.length > 0);
|
||||
|
||||
// Also check common locations
|
||||
const commonPaths = [
|
||||
`${homedir()}/docker-compose.yml`,
|
||||
'/opt/docker-compose.yml',
|
||||
'/root/docker-compose.yml'
|
||||
];
|
||||
|
||||
return [...new Set([...files, ...commonPaths])];
|
||||
}
|
||||
|
||||
// Extract a specific service from docker-compose.yml
|
||||
function extractServiceFromYaml(fullYaml: string, serviceName: string): string {
|
||||
const lines = fullYaml.split('\n');
|
||||
let inService = false;
|
||||
let serviceLines: string[] = [];
|
||||
let indentLevel = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check if we're starting the target service
|
||||
if (line.match(new RegExp(`^\\s+${serviceName}:`)) || line.match(new RegExp(`^${serviceName}:`))) {
|
||||
inService = true;
|
||||
indentLevel = line.match(/^(\s*)/)![1].length;
|
||||
serviceLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inService) {
|
||||
const currentIndent = line.match(/^(\s*)/)![1].length;
|
||||
|
||||
// If we've dedented back to or past the service level, we're done
|
||||
if (line.trim() && currentIndent <= indentLevel) {
|
||||
// Check if this is another top-level key (services, volumes, networks)
|
||||
if (line.match(/^(services|volumes|networks):/)) {
|
||||
break;
|
||||
}
|
||||
// If this is another service, we're done
|
||||
if (!line.startsWith(' ')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
serviceLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceLines.length === 0) {
|
||||
return `# Service "${serviceName}" not found in docker-compose.yml`;
|
||||
}
|
||||
|
||||
return serviceLines.join('\n');
|
||||
}
|
||||
|
||||
// Read docker-compose.yml from remote host and extract service
|
||||
async function getContainerConfig(conn: Client, containerName: string): Promise<ConfigResponse> {
|
||||
const files = await findDockerComposeFiles(conn);
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const command = `cat "${filePath}"`;
|
||||
const content = await execSSH(conn, command);
|
||||
|
||||
if (content && content.includes(containerName)) {
|
||||
const serviceConfig = extractServiceFromYaml(content, containerName);
|
||||
return {
|
||||
yaml: serviceConfig,
|
||||
path: filePath
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error reading ${filePath}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `No docker-compose.yml found with service "${containerName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/config/:host/:container
|
||||
router.get('/config/:host/:container', async (req, res) => {
|
||||
const { host, container } = req.params;
|
||||
|
||||
console.log(`Fetching config for ${container} on ${host}`);
|
||||
|
||||
try {
|
||||
// Find host config
|
||||
const hostConfigs = getHostConfigs();
|
||||
const hostConfig = hostConfigs.find(h => h.name === host);
|
||||
|
||||
if (!hostConfig) {
|
||||
return res.status(404).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `Host "${host}" not found in configuration`
|
||||
});
|
||||
}
|
||||
|
||||
// Read SSH key
|
||||
let privateKey: Buffer | undefined;
|
||||
if (hostConfig.sshKeyPath) {
|
||||
const keyPath = hostConfig.sshKeyPath.replace(/^~/, homedir());
|
||||
try {
|
||||
privateKey = readFileSync(keyPath);
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `Failed to read SSH key: ${err}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to host via SSH
|
||||
const conn = await connectSSH({
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.sshPort || 22,
|
||||
username: hostConfig.sshUser,
|
||||
privateKey
|
||||
}, 30000);
|
||||
|
||||
// Get container config
|
||||
const config = await getContainerConfig(conn, container);
|
||||
conn.end();
|
||||
|
||||
res.json(config);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching config:', err);
|
||||
res.status(500).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: err.message || 'Failed to fetch config'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
167
server/routes/stats.ts
Normal file
167
server/routes/stats.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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 = 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;
|
||||
Reference in New Issue
Block a user