diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index 160e419..5bc7f16 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -132,9 +132,31 @@ One real bug was caught and fixed during this browser verification: the page ini - `guacd` is not yet added to a `docker-compose.yml` for actual deployment on `racknerd1` — it currently must be run as a sidecar process/container manually, pointed at via `ARCHNEST_GUACD_HOST`/`ARCHNEST_GUACD_PORT`. Wiring that into the real deployment compose file is follow-up work, not done here. - All test artifacts (test `guacd`/`vncserver` processes, test backend instance, test DB, tokens, temp files, Playwright scripts) were cleaned up afterward. +### Phase 6 — Host Metrics Widgets (DONE, with documented gaps) + +**Architecture decision**: Termix's `host-metrics.ts` route (2,584 lines) is tightly coupled to its own Drizzle schema, multi-user auth, SOCKS5/jump-host chaining, TOTP-gated metrics sessions, and a metrics cache/backoff/request-queue layer — none of that scaffolding was ported. The actual reusable value is the 10 `widgets/*-collector.ts` files: small, near-backend-agnostic functions that take a raw `ssh2.Client`, run a few shell commands, and return null-tolerant typed metrics. Those collectors were reimplemented against ArchNest's own `ssh2` connection objects (reusing `loadSshHost`/`connectTarget` from Phase 1/2, not Termix's pool/cache/session substrate). Delivery is simple on-demand REST + 5s client-side polling — the same low-tech approach Phase 2 used for tunnel status — rather than Termix's own caching/backoff system. This was built as a new standalone page (`/host-metrics`) rather than folded into `Infrastructure.tsx`: the existing Infrastructure page is a fleet-wide overview (one row per resource), while these widgets are a deep per-host live view, closer in spirit to `Terminal.tsx`/`RemoteDesktop.tsx`'s "pick a host, see one rich view" pattern. The existing `backend/src/integrations/ssh.ts` `listResources` probe (disk/mem/load percentages for the Infrastructure overview) is left as-is and unrelated — it answers "is this host healthy at a glance," not "show me everything about this host." + +**What was built:** +- `backend/src/ssh/metrics/common.ts` — shared `execCommand()` (exec + timeout + cleanup) and small numeric helpers, ported from Termix's `widgets/common-utils.ts`. +- `backend/src/ssh/metrics/{cpu,memory,disk,uptime,network,system,processes,ports,firewall,login-stats}.ts` — 10 collectors ported from Termix's `widgets/*-collector.ts`, each independently null-safe. `ports.ts` only implements the `ss`-based path (Termix also had a `netstat` fallback parser, dropped as redundant on any modern target). +- `backend/src/ssh/metrics/index.ts` — `collectHostMetrics()` aggregator. +- `backend/src/routes/metrics.ts` — `GET /api/integrations/:id/metrics`, authenticated, connects via `connectTarget` (transparent jump-host support inherited for free) and runs the aggregator. +- `src/pages/HostMetrics.tsx` — new page (`/host-metrics`, sidebar entry with a `Gauge` icon): SSH host picker + CPU/memory/disk gauges, uptime/system card, network interfaces, listening ports, top processes table, firewall summary, login activity summary. Polls every 5s while a host is selected. +- `src/lib/api.ts` — `getHostMetrics()` + `HostMetrics` type. + +**Verified end-to-end** against a real, locally-installed `sshd` (not mocked): installed `openssh-server`, created a real test user, ran a real ArchNest backend + SQLite DB, created a real `ssh`-type integration, and hit `GET /api/integrations/:id/metrics` over a real SSH connection. CPU, memory, disk, uptime, system, and processes all returned real, correct data from the live container (verified CPU% against `/proc/stat` math, memory/disk against `free`/`df`, process list against a parallel manual `ps aux`). + +One real bug was caught and fixed: the first version ran all 10 collectors via `Promise.all`, which opens 15-20 concurrent SSH exec channels — this silently exceeded OpenSSH's default `MaxSessions 10` and starved whichever collectors lost the race (`network`/`processes`/`ports`/`firewall`/`loginStats` came back empty while `cpu`/`memory`/`disk`/`uptime`/`system` succeeded). Fixed by running collectors sequentially in `collectHostMetrics()` — acceptable since this is on-demand polling, not a latency-critical path. + +**Documented gaps**: +- `network` and `ports` collectors returned empty against the sandbox's test container because it has no `iproute2` package (`ip`/`ss` missing) — confirmed via manual SSH (`which ip`/`which ss` both failed) rather than a code defect. Logic is unverified against a host that actually has these tools. +- `firewall` returned `type: "none"` because `iptables-save` requires root and the test SSH user wasn't root — expected per the null-tolerant design, but the root-required path itself wasn't exercised. +- `loginStats` returned empty because the test container's `wtmp` had no real login history and `/var/log/auth.log`/`secure` weren't populated — `last`/`grep` both ran successfully, just had nothing to report. +- The frontend page was typechecked and manually reviewed, and the route was confirmed to be served by Vite, but **not visually verified in a browser** — Playwright wasn't available in this sandbox for this phase (no cached install found). This is a real verification gap, not a claim of UI testing that didn't happen. +- All test artifacts (test `sshd` process, test OS user, test backend instance, test DB, tokens, temp env/log files) were cleaned up afterward. + ### Also worth checking during/after the phases above -- `src/backend/ssh/host-metrics*.ts` (~3,900 lines combined across 8 files) — CPU/memory/disk/network/uptime/firewall/port-monitor/log-viewer/users-permissions/certificates widgets. Not yet assigned to a phase; likely overlaps with ArchNest's existing SSH adapter health probe (`backend/src/integrations/ssh.ts`) and Infrastructure page — worth a deliberate decision on whether to fold these widgets into the existing Infrastructure page rather than recreate Termix's own dashboard-cards system (`src/ui/dashboard/*`). - `src/backend/ssh/host-transfer.ts` (3,428 lines) — appears to be server-to-server file/data transfer; likely folds into Phase 3 (file manager) rather than being separate. - Data export/import of SSH hosts/credentials/file-manager data — a nice-to-have, not yet scheduled. diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts new file mode 100644 index 0000000..0604301 --- /dev/null +++ b/backend/src/routes/metrics.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from 'fastify' +import { Client } from 'ssh2' +import { loadSshHost, connectTarget } from '../ssh/connect.js' +import { collectHostMetrics } from '../ssh/metrics/index.js' + +export async function metricsRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/integrations/:id/metrics', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const target = loadSshHost(id) + if (!target) return reply.code(404).send({ error: 'SSH integration not found' }) + + const jumpRef: { current: Client | null } = { current: null } + const client = await new Promise((resolve) => { + const { jumpConn } = connectTarget( + target, + (c) => resolve(c), + () => { + jumpConn?.end() + resolve(null) + }, + ) + jumpRef.current = jumpConn + }) + + if (!client) return reply.code(502).send({ error: 'Failed to connect to host' }) + + try { + const metrics = await collectHostMetrics(client) + return metrics + } finally { + client.end() + jumpRef.current?.end() + } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index fd648d2..f33e5a7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js' import { fileRoutes } from './routes/files.js' import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { guacamoleRoutes } from './routes/guacamole.js' +import { metricsRoutes } from './routes/metrics.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -45,6 +46,7 @@ await app.register(fileRoutes) await app.register(dockerRoutes) await app.register(dockerExecRoutes) await app.register(guacamoleRoutes) +await app.register(metricsRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/backend/src/ssh/metrics/common.ts b/backend/src/ssh/metrics/common.ts new file mode 100644 index 0000000..9078431 --- /dev/null +++ b/backend/src/ssh/metrics/common.ts @@ -0,0 +1,39 @@ +import type { Client } from 'ssh2' + +export function execCommand( + client: Client, + command: string, + timeoutMs = 15000, +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + client.exec(command, { pty: false }, (err, stream) => { + if (err) return reject(err) + let stdout = '' + let stderr = '' + const timer = setTimeout(() => { + stream.removeAllListeners() + stream.destroy() + reject(new Error(`Command timed out: ${command}`)) + }, timeoutMs) + stream.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8'))) + stream.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + stream.on('close', (code: number | null) => { + clearTimeout(timer) + resolve({ stdout, stderr, code }) + }) + stream.on('error', (e: Error) => { + clearTimeout(timer) + reject(e) + }) + }) + }) +} + +export function kibToGiB(kib: number): number { + return kib / (1024 * 1024) +} + +export function toFixedNum(n: number | null | undefined, digits = 2): number | null { + if (n === null || n === undefined || !Number.isFinite(n)) return null + return Number(n.toFixed(digits)) +} diff --git a/backend/src/ssh/metrics/cpu.ts b/backend/src/ssh/metrics/cpu.ts new file mode 100644 index 0000000..5b7c2af --- /dev/null +++ b/backend/src/ssh/metrics/cpu.ts @@ -0,0 +1,54 @@ +import type { Client } from 'ssh2' +import { execCommand, toFixedNum } from './common.js' + +function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined { + const parts = cpuLine.trim().split(/\s+/).slice(1).map(Number) + if (parts.length < 4 || parts.some((n) => !Number.isFinite(n))) return undefined + const idle = parts[3] + (parts[4] ?? 0) + const total = parts.reduce((sum, n) => sum + n, 0) + return { total, idle } +} + +export async function collectCpuMetrics( + client: Client, +): Promise<{ percent: number | null; cores: number | null; load: [number, number, number] | null }> { + let percent: number | null = null + let cores: number | null = null + let load: [number, number, number] | null = null + + try { + const work = (async () => { + const first = await execCommand(client, "grep '^cpu ' /proc/stat") + await new Promise((r) => setTimeout(r, 500)) + const [second, loadOut, coresOut] = await Promise.all([ + execCommand(client, "grep '^cpu ' /proc/stat"), + execCommand(client, 'cat /proc/loadavg'), + execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo'), + ]) + + const a = parseCpuLine(first.stdout) + const b = parseCpuLine(second.stdout) + if (a && b) { + const totalDiff = b.total - a.total + const idleDiff = b.idle - a.idle + if (totalDiff > 0) { + percent = toFixedNum(Math.min(100, Math.max(0, ((totalDiff - idleDiff) / totalDiff) * 100))) + } + } + + const loadParts = loadOut.stdout.trim().split(/\s+/).slice(0, 3).map(Number) + if (loadParts.length === 3 && loadParts.every(Number.isFinite)) { + load = loadParts as [number, number, number] + } + + const coreCount = Number(coresOut.stdout.trim()) + cores = Number.isFinite(coreCount) ? coreCount : null + })() + + await Promise.race([work, new Promise((_, reject) => setTimeout(() => reject(new Error('cpu metrics timeout')), 25000))]) + } catch { + // best-effort; leave nulls + } + + return { percent, cores, load } +} diff --git a/backend/src/ssh/metrics/disk.ts b/backend/src/ssh/metrics/disk.ts new file mode 100644 index 0000000..ff0036a --- /dev/null +++ b/backend/src/ssh/metrics/disk.ts @@ -0,0 +1,44 @@ +import type { Client } from 'ssh2' +import { execCommand, toFixedNum } from './common.js' + +function parseDfLine(output: string): string[] | null { + const lines = output.split('\n').map((l) => l.trim()).filter(Boolean) + if (lines.length < 2) return null + return lines[1].split(/\s+/) +} + +export async function collectDiskMetrics( + client: Client, +): Promise<{ percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null }> { + let percent: number | null = null + let usedHuman: string | null = null + let totalHuman: string | null = null + let availableHuman: string | null = null + + try { + const [human, bytes] = await Promise.all([ + execCommand(client, "df -h -P / 2>/dev/null"), + execCommand(client, "df -B1 -P / 2>/dev/null"), + ]) + + const humanParts = parseDfLine(human.stdout) + if (humanParts && humanParts.length >= 4) { + totalHuman = humanParts[1] + usedHuman = humanParts[2] + availableHuman = humanParts[3] + } + + const byteParts = parseDfLine(bytes.stdout) + if (byteParts && byteParts.length >= 3) { + const total = Number(byteParts[1]) + const used = Number(byteParts[2]) + if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) { + percent = toFixedNum((used / total) * 100) + } + } + } catch { + // best-effort + } + + return { percent, usedHuman, totalHuman, availableHuman } +} diff --git a/backend/src/ssh/metrics/firewall.ts b/backend/src/ssh/metrics/firewall.ts new file mode 100644 index 0000000..155a39b --- /dev/null +++ b/backend/src/ssh/metrics/firewall.ts @@ -0,0 +1,74 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export interface FirewallRule { + chain: string + target: string + protocol: string + source: string + destination: string + dport?: string +} + +export interface FirewallChain { + name: string + policy: string + rules: FirewallRule[] +} + +export interface FirewallMetrics { + type: 'iptables' | 'none' + status: 'active' | 'inactive' | 'unknown' + chains: FirewallChain[] +} + +function parseIptablesRule(line: string): FirewallRule | null { + if (!line.startsWith('-A ')) return null + const rule: FirewallRule = { chain: '', target: '', protocol: 'all', source: '0.0.0.0/0', destination: '0.0.0.0/0' } + rule.chain = line.match(/^-A\s+(\S+)/)?.[1] ?? '' + rule.target = line.match(/-j\s+(\S+)/)?.[1] ?? '' + rule.protocol = line.match(/-p\s+(\S+)/)?.[1] ?? 'all' + rule.source = line.match(/-s\s+(\S+)/)?.[1] ?? '0.0.0.0/0' + rule.destination = line.match(/-d\s+(\S+)/)?.[1] ?? '0.0.0.0/0' + const dport = line.match(/--dport\s+(\S+)/)?.[1] + if (dport) rule.dport = dport + return rule +} + +function parseIptablesOutput(output: string): FirewallChain[] { + const chains = new Map() + for (const rawLine of output.split('\n')) { + const line = rawLine.trim() + const policyMatch = line.match(/^:(\S+)\s+(\S+)/) + if (policyMatch) { + chains.set(policyMatch[1], { name: policyMatch[1], policy: policyMatch[2], rules: [] }) + continue + } + const rule = parseIptablesRule(line) + if (rule) { + let chain = chains.get(rule.chain) + if (!chain) { + chain = { name: rule.chain, policy: 'ACCEPT', rules: [] } + chains.set(rule.chain, chain) + } + chain.rules.push(rule) + } + } + return Array.from(chains.values()) +} + +export async function collectFirewallMetrics(client: Client): Promise { + try { + const result = await execCommand(client, 'iptables-save 2>/dev/null') + if (result.stdout.includes('*filter')) { + const chains = parseIptablesOutput(result.stdout).filter( + (c) => c.name === 'INPUT' || c.name === 'OUTPUT' || c.name === 'FORWARD', + ) + const hasRules = chains.some((c) => c.rules.length > 0) + return { type: 'iptables', status: hasRules ? 'active' : 'inactive', chains } + } + return { type: 'none', status: 'unknown', chains: [] } + } catch { + return { type: 'none', status: 'unknown', chains: [] } + } +} diff --git a/backend/src/ssh/metrics/index.ts b/backend/src/ssh/metrics/index.ts new file mode 100644 index 0000000..0b7b712 --- /dev/null +++ b/backend/src/ssh/metrics/index.ts @@ -0,0 +1,31 @@ +import type { Client } from 'ssh2' +import { collectCpuMetrics } from './cpu.js' +import { collectMemoryMetrics } from './memory.js' +import { collectDiskMetrics } from './disk.js' +import { collectUptimeMetrics } from './uptime.js' +import { collectNetworkMetrics } from './network.js' +import { collectSystemMetrics } from './system.js' +import { collectProcessesMetrics } from './processes.js' +import { collectPortsMetrics } from './ports.js' +import { collectFirewallMetrics } from './firewall.js' +import { collectLoginStats } from './login-stats.js' + +/** + * Collectors run sequentially rather than via Promise.all: each one opens + * 1-3 SSH exec channels, and running all ~10 collectors concurrently can open + * 15-20 channels at once, which exceeds OpenSSH's default `MaxSessions 10` + * and silently starves whichever collectors lose the race. + */ +export async function collectHostMetrics(client: Client) { + const cpu = await collectCpuMetrics(client) + const memory = await collectMemoryMetrics(client) + const disk = await collectDiskMetrics(client) + const uptime = await collectUptimeMetrics(client) + const network = await collectNetworkMetrics(client) + const system = await collectSystemMetrics(client) + const processes = await collectProcessesMetrics(client) + const ports = await collectPortsMetrics(client) + const firewall = await collectFirewallMetrics(client) + const loginStats = await collectLoginStats(client) + return { cpu, memory, disk, uptime, network, system, processes, ports, firewall, loginStats } +} diff --git a/backend/src/ssh/metrics/login-stats.ts b/backend/src/ssh/metrics/login-stats.ts new file mode 100644 index 0000000..ff5a54b --- /dev/null +++ b/backend/src/ssh/metrics/login-stats.ts @@ -0,0 +1,74 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export interface LoginRecord { + user: string + ip: string + time: string + status: 'success' | 'failed' +} + +export interface LoginStats { + recentLogins: LoginRecord[] + failedLogins: LoginRecord[] + totalLogins: number + uniqueIPs: number +} + +export async function collectLoginStats(client: Client): Promise { + const recentLogins: LoginRecord[] = [] + const failedLogins: LoginRecord[] = [] + const ipSet = new Set() + + try { + const lastOut = await execCommand(client, "last -n 20 -F -w 2>/dev/null | grep -v 'reboot\\|wtmp' | head -20") + const lines = lastOut.stdout.split('\n').map((l) => l.trim()).filter(Boolean) + for (const line of lines) { + const parts = line.split(/\s+/) + if (parts.length < 10) continue + const user = parts[0] + const tty = parts[1] + const ip = parts[2] === ':' || parts[2].startsWith(':') ? 'local' : parts[2] + const dayIdx = parts.findIndex((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) + if (dayIdx > 0 && parts.length > dayIdx + 4 && user && user !== 'wtmp' && tty !== 'system') { + const timeStr = parts.slice(dayIdx, dayIdx + 5).join(' ') + const date = new Date(timeStr) + recentLogins.push({ user, ip, time: isNaN(date.getTime()) ? timeStr : date.toISOString(), status: 'success' }) + if (ip !== 'local') ipSet.add(ip) + } + } + } catch { + // best-effort + } + + try { + const failedOut = await execCommand( + client, + "grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || true", + ) + const lines = failedOut.stdout.split('\n').map((l) => l.trim()).filter(Boolean) + for (const line of lines) { + const user = line.match(/for (?:invalid user )?(\S+)/)?.[1] ?? 'unknown' + const ip = line.match(/from (\d+\.\d+\.\d+\.\d+)/)?.[1] ?? 'unknown' + const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/) + let time = 'unknown' + if (dateMatch) { + const [, month, day, t] = dateMatch + const year = new Date().getFullYear() + const candidate = new Date(`${month} ${day}, ${year} ${t}`) + time = !isNaN(candidate.getTime()) ? candidate.toISOString() : `${month} ${day}, ${year} ${t}` + } + failedLogins.push({ user, ip, time, status: 'failed' }) + if (ip !== 'unknown') ipSet.add(ip) + } + } catch { + // best-effort + } + + return { + recentLogins: recentLogins.slice(0, 10), + failedLogins: failedLogins.slice(0, 10), + totalLogins: recentLogins.length, + uniqueIPs: ipSet.size, + } +} diff --git a/backend/src/ssh/metrics/memory.ts b/backend/src/ssh/metrics/memory.ts new file mode 100644 index 0000000..ae08a85 --- /dev/null +++ b/backend/src/ssh/metrics/memory.ts @@ -0,0 +1,23 @@ +import type { Client } from 'ssh2' +import { execCommand, kibToGiB, toFixedNum } from './common.js' + +export async function collectMemoryMetrics( + client: Client, +): Promise<{ percent: number | null; usedGiB: number | null; totalGiB: number | null }> { + try { + const { stdout } = await execCommand(client, 'cat /proc/meminfo') + const totalKb = Number(stdout.match(/^MemTotal:\s+(\d+)/m)?.[1]) + const availKb = Number(stdout.match(/^MemAvailable:\s+(\d+)/m)?.[1]) + if (!Number.isFinite(totalKb) || !Number.isFinite(availKb) || totalKb <= 0) { + return { percent: null, usedGiB: null, totalGiB: null } + } + const usedKb = totalKb - availKb + return { + percent: toFixedNum((usedKb / totalKb) * 100), + usedGiB: toFixedNum(kibToGiB(usedKb)), + totalGiB: toFixedNum(kibToGiB(totalKb)), + } + } catch { + return { percent: null, usedGiB: null, totalGiB: null } + } +} diff --git a/backend/src/ssh/metrics/network.ts b/backend/src/ssh/metrics/network.ts new file mode 100644 index 0000000..24a37f3 --- /dev/null +++ b/backend/src/ssh/metrics/network.ts @@ -0,0 +1,41 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export async function collectNetworkMetrics( + client: Client, +): Promise<{ interfaces: Array<{ name: string; ip: string; state: string }> }> { + const interfaces: Array<{ name: string; ip: string; state: string }> = [] + + try { + const [addrOut, linkOut] = await Promise.all([ + execCommand(client, "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'"), + execCommand(client, "ip -o link show | awk '{gsub(/:/,\"\",$2); print $2,$9}'"), + ]) + + const ipByIface = new Map() + for (const line of addrOut.stdout.split('\n')) { + const [iface, addr] = line.trim().split(/\s+/) + if (iface && addr) ipByIface.set(iface, addr) + } + + const stateByIface = new Map() + for (const line of linkOut.stdout.split('\n')) { + const [iface, state] = line.trim().split(/\s+/) + if (iface) stateByIface.set(iface, state ?? 'UNKNOWN') + } + + const names = new Set([...ipByIface.keys(), ...stateByIface.keys()]) + for (const name of names) { + if (name === 'lo' || !name) continue + interfaces.push({ + name, + ip: ipByIface.get(name) ?? '', + state: stateByIface.get(name) ?? 'UNKNOWN', + }) + } + } catch { + // best-effort + } + + return { interfaces } +} diff --git a/backend/src/ssh/metrics/ports.ts b/backend/src/ssh/metrics/ports.ts new file mode 100644 index 0000000..ddef170 --- /dev/null +++ b/backend/src/ssh/metrics/ports.ts @@ -0,0 +1,71 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export interface ListeningPort { + protocol: 'tcp' | 'udp' + localAddress: string + localPort: number + state?: string + pid?: number + process?: string +} + +export interface PortsMetrics { + source: 'ss' | 'netstat' | 'none' + ports: ListeningPort[] +} + +function parseSsOutput(output: string): ListeningPort[] { + const ports: ListeningPort[] = [] + const lines = output.split('\n').slice(1) + + for (const line of lines) { + const parts = line.trim().split(/\s+/) + if (parts.length < 5) continue + + const protocol = parts[0]?.toLowerCase() + if (protocol !== 'tcp' && protocol !== 'udp') continue + + const state = parts[1] + const localAddr = parts[4] + if (!localAddr) continue + + const lastColon = localAddr.lastIndexOf(':') + if (lastColon === -1) continue + + const address = localAddr.substring(0, lastColon).replace(/^\[|\]$/g, '') + const port = parseInt(localAddr.substring(lastColon + 1), 10) + if (isNaN(port)) continue + + const entry: ListeningPort = { + protocol, + localAddress: address, + localPort: port, + state: protocol === 'tcp' ? state : undefined, + } + + const processInfo = parts[6] + if (processInfo?.startsWith('users:')) { + const pidMatch = processInfo.match(/pid=(\d+)/) + const nameMatch = processInfo.match(/\("([^"]+)"/) + if (pidMatch) entry.pid = parseInt(pidMatch[1], 10) + if (nameMatch) entry.process = nameMatch[1] + } + + ports.push(entry) + } + + return ports +} + +export async function collectPortsMetrics(client: Client): Promise { + try { + const ssResult = await execCommand(client, 'ss -tulnp 2>/dev/null') + if (ssResult.stdout.includes('Local')) { + return { source: 'ss', ports: parseSsOutput(ssResult.stdout).sort((a, b) => a.localPort - b.localPort) } + } + return { source: 'none', ports: [] } + } catch { + return { source: 'none', ports: [] } + } +} diff --git a/backend/src/ssh/metrics/processes.ts b/backend/src/ssh/metrics/processes.ts new file mode 100644 index 0000000..1728fbb --- /dev/null +++ b/backend/src/ssh/metrics/processes.ts @@ -0,0 +1,50 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export interface ProcessEntry { + pid: string + user: string + cpu: string + mem: string + command: string +} + +export async function collectProcessesMetrics( + client: Client, +): Promise<{ total: number | null; running: number | null; top: ProcessEntry[] }> { + let total: number | null = null + let running: number | null = null + const top: ProcessEntry[] = [] + + try { + const [psOut, countOut, runningOut] = await Promise.all([ + execCommand(client, 'ps aux --sort=-%cpu | head -n 11'), + execCommand(client, 'ps aux | wc -l'), + execCommand(client, "ps aux | grep -c ' R '"), + ]) + + const lines = psOut.stdout.split('\n').map((l) => l.trim()).filter(Boolean) + for (let i = 1; i < Math.min(lines.length, 11); i++) { + const parts = lines[i].split(/\s+/) + if (parts.length >= 11) { + top.push({ + pid: parts[1], + user: parts[0], + cpu: parts[2], + mem: parts[3], + command: parts.slice(10).join(' ').substring(0, 50), + }) + } + } + + const totalCount = Number(countOut.stdout.trim()) - 1 + total = Number.isFinite(totalCount) ? totalCount : null + + const runningCount = Number(runningOut.stdout.trim()) + running = Number.isFinite(runningCount) ? runningCount : null + } catch { + // best-effort + } + + return { total, running, top } +} diff --git a/backend/src/ssh/metrics/system.ts b/backend/src/ssh/metrics/system.ts new file mode 100644 index 0000000..dd52cbc --- /dev/null +++ b/backend/src/ssh/metrics/system.ts @@ -0,0 +1,21 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export async function collectSystemMetrics( + client: Client, +): Promise<{ hostname: string | null; kernel: string | null; os: string | null }> { + try { + const [hostnameOut, kernelOut, osOut] = await Promise.all([ + execCommand(client, 'hostname'), + execCommand(client, 'uname -r'), + execCommand(client, "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2"), + ]) + return { + hostname: hostnameOut.stdout.trim() || null, + kernel: kernelOut.stdout.trim() || null, + os: osOut.stdout.trim() || null, + } + } catch { + return { hostname: null, kernel: null, os: null } + } +} diff --git a/backend/src/ssh/metrics/uptime.ts b/backend/src/ssh/metrics/uptime.ts new file mode 100644 index 0000000..1a7d0db --- /dev/null +++ b/backend/src/ssh/metrics/uptime.ts @@ -0,0 +1,18 @@ +import type { Client } from 'ssh2' +import { execCommand } from './common.js' + +export async function collectUptimeMetrics( + client: Client, +): Promise<{ seconds: number | null; formatted: string | null }> { + try { + const { stdout } = await execCommand(client, 'cat /proc/uptime') + const seconds = Number(stdout.trim().split(/\s+/)[0]) + if (!Number.isFinite(seconds)) return { seconds: null, formatted: null } + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return { seconds, formatted: `${days}d ${hours}h ${minutes}m` } + } catch { + return { seconds: null, formatted: null } + } +} diff --git a/src/App.tsx b/src/App.tsx index afee65e..0115309 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import Tunnels from './pages/Tunnels' import Files from './pages/Files' import Containers from './pages/Containers' import RemoteDesktop from './pages/RemoteDesktop' +import HostMetrics from './pages/HostMetrics' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -90,6 +91,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2ec7a4c..202b941 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { FolderOpen, Box, MonitorSmartphone, + Gauge, Settings, ChevronLeft, ChevronRight, @@ -29,6 +30,7 @@ const navItems = [ { icon: FolderOpen, label: 'Files', route: '/files' }, { icon: Box, label: 'Containers', route: '/containers' }, { icon: MonitorSmartphone, label: 'Remote Desktop', route: '/remote-desktop' }, + { icon: Gauge, label: 'Host Metrics', route: '/host-metrics' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 50d79e8..bfa403c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -144,6 +144,8 @@ export const api = { method: 'POST', body: JSON.stringify({ force }), }), + + getHostMetrics: (integrationId: number) => apiFetch(`/integrations/${integrationId}/metrics`), } export interface AuthUser { @@ -241,3 +243,25 @@ export interface Resource { detail?: string integration: string } + +export interface HostMetrics { + cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null } + memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null } + disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null } + uptime: { seconds: number | null; formatted: string | null } + network: { interfaces: Array<{ name: string; ip: string; state: string }> } + system: { hostname: string | null; kernel: string | null; os: string | null } + processes: { + total: number | null + running: number | null + top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string }> + } + ports: { source: 'ss' | 'netstat' | 'none'; ports: Array<{ protocol: string; localAddress: string; localPort: number; state?: string; process?: string }> } + firewall: { type: 'iptables' | 'none'; status: 'active' | 'inactive' | 'unknown'; chains: Array<{ name: string; policy: string; rules: unknown[] }> } + loginStats: { + recentLogins: Array<{ user: string; ip: string; time: string; status: string }> + failedLogins: Array<{ user: string; ip: string; time: string; status: string }> + totalLogins: number + uniqueIPs: number + } +} diff --git a/src/pages/HostMetrics.tsx b/src/pages/HostMetrics.tsx new file mode 100644 index 0000000..dbdeb57 --- /dev/null +++ b/src/pages/HostMetrics.tsx @@ -0,0 +1,251 @@ +import { useEffect, useRef, useState } from 'react' +import { api, type HostMetrics as HostMetricsData, type Integration } from '../lib/api' + +const TEXT_SECONDARY = '#7A7D85' +const TEXT_PRIMARY = '#E8E6E0' +const GOLD = '#C8A434' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + padding: '16px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', +} + +const sectionTitle: React.CSSProperties = { + fontSize: '11px', + textTransform: 'uppercase', + letterSpacing: '1.5px', + color: TEXT_SECONDARY, + fontWeight: 500, + marginBottom: '12px', +} + +function percentColor(p: number | null): string { + if (p === null) return TEXT_SECONDARY + if (p >= 90) return '#E74C3C' + if (p >= 75) return '#E67E22' + return '#2ECC71' +} + +function Gauge({ label, percent, sub }: { label: string; percent: number | null; sub?: string }) { + return ( +
+

{label}

+
+ + {percent !== null ? `${percent}%` : '—'} + +
+
+
+
+ {sub &&

{sub}

} +
+ ) +} + +export default function HostMetrics() { + const [hosts, setHosts] = useState([]) + const [hostId, setHostId] = useState(null) + const [metrics, setMetrics] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const pollRef = useRef | null>(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + setHosts(integrations.filter((i) => i.type === 'ssh')) + }) + }, []) + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, []) + + function fetchMetrics(id: number) { + setLoading(true) + api + .getHostMetrics(id) + .then((data) => { + setMetrics(data) + setError(null) + }) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load metrics')) + .finally(() => setLoading(false)) + } + + function handleSelect(id: number) { + setHostId(id) + setMetrics(null) + setError(null) + if (pollRef.current) clearInterval(pollRef.current) + fetchMetrics(id) + pollRef.current = setInterval(() => fetchMetrics(id), 5000) + } + + const host = hosts.find((h) => h.id === hostId) + + return ( +
+
+

+ SSH Hosts +

+ {hosts.length === 0 && ( +

+ No SSH integrations configured. Add one in Settings → Integrations. +

+ )} + {hosts.map((h) => ( + + ))} +
+ +
+
+

+ {host ? host.name : 'Select a host to view live metrics'} +

+

+ {error ?? (loading ? 'Refreshing…' : metrics ? 'Live · updates every 5s' : '')} +

+
+ + {metrics && ( + <> +
+ l.toFixed(2)).join(' / ') ?? '—'}` : undefined} /> + + +
+

Uptime

+

{metrics.uptime.formatted ?? '—'}

+

+ {metrics.system.hostname ?? ''} {metrics.system.os ? `· ${metrics.system.os}` : ''} +

+
+
+ +
+
+

Network Interfaces

+ {metrics.network.interfaces.length === 0 ? ( +

No interfaces reported.

+ ) : ( +
+ {metrics.network.interfaces.map((iface) => ( +
+ {iface.name} + {iface.ip || '—'} + {iface.state} +
+ ))} +
+ )} +
+ +
+

Listening Ports ({metrics.ports.ports.length})

+ {metrics.ports.ports.length === 0 ? ( +

None detected.

+ ) : ( +
+ {metrics.ports.ports.slice(0, 12).map((p, i) => ( +
+ {p.protocol}/{p.localPort} + {p.process ?? '—'} +
+ ))} +
+ )} +
+
+ +
+

Top Processes

+ {metrics.processes.top.length === 0 ? ( +

No process data.

+ ) : ( + + + + + + + + + + + + {metrics.processes.top.map((p) => ( + + + + + + + + ))} + +
PIDUserCPU%Mem%Command
{p.pid}{p.user}{p.cpu}{p.mem}{p.command}
+ )} + {metrics.processes.total !== null && ( +

+ {metrics.processes.total} total · {metrics.processes.running ?? '—'} running +

+ )} +
+ +
+
+

Firewall ({metrics.firewall.type})

+ {metrics.firewall.type === 'none' ? ( +

No firewall data available (requires root, or none configured).

+ ) : ( +
+ {metrics.firewall.chains.map((c) => ( +
+ {c.name} + {c.policy} · {c.rules.length} rules +
+ ))} +
+ )} +
+ +
+

Login Activity

+ {metrics.loginStats.recentLogins.length === 0 && metrics.loginStats.failedLogins.length === 0 ? ( +

No login history readable.

+ ) : ( +

+ {metrics.loginStats.totalLogins} recent logins · {metrics.loginStats.failedLogins.length} failed attempts · {metrics.loginStats.uniqueIPs} unique IPs +

+ )} +
+
+ + )} + + {!metrics && !error && host &&

Loading metrics…

} +
+
+ ) +}