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:
2026-02-20 20:35:08 -08:00
parent 3dc5d236a2
commit 6dd679b8e0
14455 changed files with 3862 additions and 2194786 deletions

49
server/routes/AGENTS.md Normal file
View 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
View 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
View 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;