Add host metrics widgets (Phase 6): CPU/mem/disk/network/processes/ports/firewall/login dashboard

Ports Termix's per-host metrics collector logic onto ArchNest's own SSH
connection helpers (not its multi-user/cache/session scaffolding), exposed via
a new authenticated REST endpoint and a dedicated /host-metrics page with
client-side polling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-19 15:38:30 +00:00
parent c37ad3d0d4
commit f32d93947b
No known key found for this signature in database
19 changed files with 881 additions and 1 deletions

View file

@ -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. - `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. - 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 ### 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. - `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. - Data export/import of SSH hosts/credentials/file-manager data — a nice-to-have, not yet scheduled.

View file

@ -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<Client | null>((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()
}
})
}

View file

@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js'
import { fileRoutes } from './routes/files.js' import { fileRoutes } from './routes/files.js'
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
import { guacamoleRoutes } from './routes/guacamole.js' import { guacamoleRoutes } from './routes/guacamole.js'
import { metricsRoutes } from './routes/metrics.js'
import { startAutoStartTunnels } from './tunnels/manager.js' import { startAutoStartTunnels } from './tunnels/manager.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
@ -45,6 +46,7 @@ await app.register(fileRoutes)
await app.register(dockerRoutes) await app.register(dockerRoutes)
await app.register(dockerExecRoutes) await app.register(dockerExecRoutes)
await app.register(guacamoleRoutes) await app.register(guacamoleRoutes)
await app.register(metricsRoutes)
app.get('/api/health', async () => ({ ok: true })) app.get('/api/health', async () => ({ ok: true }))

View file

@ -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))
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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<string, FirewallChain>()
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<FirewallMetrics> {
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: [] }
}
}

View file

@ -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 }
}

View file

@ -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<LoginStats> {
const recentLogins: LoginRecord[] = []
const failedLogins: LoginRecord[] = []
const ipSet = new Set<string>()
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,
}
}

View file

@ -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 }
}
}

View file

@ -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<string, string>()
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<string, string>()
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 }
}

View file

@ -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<PortsMetrics> {
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: [] }
}
}

View file

@ -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 }
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -10,6 +10,7 @@ import Tunnels from './pages/Tunnels'
import Files from './pages/Files' import Files from './pages/Files'
import Containers from './pages/Containers' import Containers from './pages/Containers'
import RemoteDesktop from './pages/RemoteDesktop' import RemoteDesktop from './pages/RemoteDesktop'
import HostMetrics from './pages/HostMetrics'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import Login from './pages/Login' import Login from './pages/Login'
import Enrollment from './pages/Enrollment' import Enrollment from './pages/Enrollment'
@ -90,6 +91,7 @@ function Dashboard() {
<Route path="/files" element={<Files />} /> <Route path="/files" element={<Files />} />
<Route path="/containers" element={<Containers />} /> <Route path="/containers" element={<Containers />} />
<Route path="/remote-desktop" element={<RemoteDesktop />} /> <Route path="/remote-desktop" element={<RemoteDesktop />} />
<Route path="/host-metrics" element={<HostMetrics />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</section> </section>

View file

@ -9,6 +9,7 @@ import {
FolderOpen, FolderOpen,
Box, Box,
MonitorSmartphone, MonitorSmartphone,
Gauge,
Settings, Settings,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -29,6 +30,7 @@ const navItems = [
{ icon: FolderOpen, label: 'Files', route: '/files' }, { icon: FolderOpen, label: 'Files', route: '/files' },
{ icon: Box, label: 'Containers', route: '/containers' }, { icon: Box, label: 'Containers', route: '/containers' },
{ icon: MonitorSmartphone, label: 'Remote Desktop', route: '/remote-desktop' }, { icon: MonitorSmartphone, label: 'Remote Desktop', route: '/remote-desktop' },
{ icon: Gauge, label: 'Host Metrics', route: '/host-metrics' },
{ icon: Settings, label: 'Settings', route: '/settings' }, { icon: Settings, label: 'Settings', route: '/settings' },
] ]

View file

@ -144,6 +144,8 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ force }), body: JSON.stringify({ force }),
}), }),
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
} }
export interface AuthUser { export interface AuthUser {
@ -241,3 +243,25 @@ export interface Resource {
detail?: string detail?: string
integration: 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
}
}

251
src/pages/HostMetrics.tsx Normal file
View file

@ -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 (
<div style={cardBase}>
<h3 style={sectionTitle}>{label}</h3>
<div className="flex items-end gap-2">
<span style={{ fontSize: '28px', fontWeight: 700, color: percentColor(percent) }}>
{percent !== null ? `${percent}%` : '—'}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full" style={{ backgroundColor: 'rgba(255,255,255,0.06)' }}>
<div
className="h-full rounded-full transition-all"
style={{ width: `${percent ?? 0}%`, backgroundColor: percentColor(percent) }}
/>
</div>
{sub && <p className="mt-2" style={{ fontSize: '11px', color: TEXT_SECONDARY }}>{sub}</p>}
</div>
)
}
export default function HostMetrics() {
const [hosts, setHosts] = useState<Integration[]>([])
const [hostId, setHostId] = useState<number | null>(null)
const [metrics, setMetrics] = useState<HostMetricsData | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
SSH Hosts
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No SSH integrations configured. Add one in Settings Integrations.
</p>
)}
{hosts.map((h) => (
<button
key={h.id}
onClick={() => handleSelect(h.id)}
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition"
style={{
background: hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: hostId === h.id ? GOLD : TEXT_PRIMARY,
}}
>
{h.name}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto pr-1">
<div className="flex items-center justify-between">
<p style={{ fontSize: '13px', color: TEXT_PRIMARY, fontWeight: 500 }}>
{host ? host.name : 'Select a host to view live metrics'}
</p>
<p style={{ fontSize: '11px', color: error ? '#E74C3C' : TEXT_SECONDARY }}>
{error ?? (loading ? 'Refreshing…' : metrics ? 'Live · updates every 5s' : '')}
</p>
</div>
{metrics && (
<>
<div className="grid grid-cols-4 gap-4">
<Gauge label="CPU" percent={metrics.cpu.percent} sub={metrics.cpu.cores ? `${metrics.cpu.cores} cores · load ${metrics.cpu.load?.map((l) => l.toFixed(2)).join(' / ') ?? '—'}` : undefined} />
<Gauge label="Memory" percent={metrics.memory.percent} sub={metrics.memory.usedGiB !== null ? `${metrics.memory.usedGiB} / ${metrics.memory.totalGiB} GiB` : undefined} />
<Gauge label="Disk (/)" percent={metrics.disk.percent} sub={metrics.disk.usedHuman ? `${metrics.disk.usedHuman} / ${metrics.disk.totalHuman} used` : undefined} />
<div style={cardBase}>
<h3 style={sectionTitle}>Uptime</h3>
<p style={{ fontSize: '22px', fontWeight: 700, color: TEXT_PRIMARY }}>{metrics.uptime.formatted ?? '—'}</p>
<p className="mt-2" style={{ fontSize: '11px', color: TEXT_SECONDARY }}>
{metrics.system.hostname ?? ''} {metrics.system.os ? `· ${metrics.system.os}` : ''}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div style={cardBase}>
<h3 style={sectionTitle}>Network Interfaces</h3>
{metrics.network.interfaces.length === 0 ? (
<p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>No interfaces reported.</p>
) : (
<div className="flex flex-col gap-1.5">
{metrics.network.interfaces.map((iface) => (
<div key={iface.name} className="flex items-center justify-between" style={{ fontSize: '12px' }}>
<span style={{ color: TEXT_PRIMARY }}>{iface.name}</span>
<span style={{ color: TEXT_SECONDARY }}>{iface.ip || '—'}</span>
<span style={{ color: iface.state === 'UP' ? '#2ECC71' : TEXT_SECONDARY }}>{iface.state}</span>
</div>
))}
</div>
)}
</div>
<div style={cardBase}>
<h3 style={sectionTitle}>Listening Ports ({metrics.ports.ports.length})</h3>
{metrics.ports.ports.length === 0 ? (
<p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>None detected.</p>
) : (
<div className="flex max-h-40 flex-col gap-1.5 overflow-y-auto">
{metrics.ports.ports.slice(0, 12).map((p, i) => (
<div key={i} className="flex items-center justify-between" style={{ fontSize: '12px' }}>
<span style={{ color: TEXT_PRIMARY }}>{p.protocol}/{p.localPort}</span>
<span style={{ color: TEXT_SECONDARY }}>{p.process ?? '—'}</span>
</div>
))}
</div>
)}
</div>
</div>
<div style={cardBase}>
<h3 style={sectionTitle}>Top Processes</h3>
{metrics.processes.top.length === 0 ? (
<p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>No process data.</p>
) : (
<table className="w-full" style={{ fontSize: '12px' }}>
<thead>
<tr style={{ color: TEXT_SECONDARY, textAlign: 'left' }}>
<th className="pb-1">PID</th>
<th className="pb-1">User</th>
<th className="pb-1">CPU%</th>
<th className="pb-1">Mem%</th>
<th className="pb-1">Command</th>
</tr>
</thead>
<tbody>
{metrics.processes.top.map((p) => (
<tr key={p.pid} style={{ color: TEXT_PRIMARY }}>
<td className="py-0.5">{p.pid}</td>
<td className="py-0.5">{p.user}</td>
<td className="py-0.5">{p.cpu}</td>
<td className="py-0.5">{p.mem}</td>
<td className="py-0.5" style={{ color: TEXT_SECONDARY }}>{p.command}</td>
</tr>
))}
</tbody>
</table>
)}
{metrics.processes.total !== null && (
<p className="mt-2" style={{ fontSize: '11px', color: TEXT_SECONDARY }}>
{metrics.processes.total} total · {metrics.processes.running ?? '—'} running
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div style={cardBase}>
<h3 style={sectionTitle}>Firewall ({metrics.firewall.type})</h3>
{metrics.firewall.type === 'none' ? (
<p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>No firewall data available (requires root, or none configured).</p>
) : (
<div className="flex flex-col gap-1.5">
{metrics.firewall.chains.map((c) => (
<div key={c.name} className="flex items-center justify-between" style={{ fontSize: '12px' }}>
<span style={{ color: TEXT_PRIMARY }}>{c.name}</span>
<span style={{ color: TEXT_SECONDARY }}>{c.policy} · {c.rules.length} rules</span>
</div>
))}
</div>
)}
</div>
<div style={cardBase}>
<h3 style={sectionTitle}>Login Activity</h3>
{metrics.loginStats.recentLogins.length === 0 && metrics.loginStats.failedLogins.length === 0 ? (
<p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>No login history readable.</p>
) : (
<p style={{ fontSize: '12px', color: TEXT_PRIMARY }}>
{metrics.loginStats.totalLogins} recent logins · {metrics.loginStats.failedLogins.length} failed attempts · {metrics.loginStats.uniqueIPs} unique IPs
</p>
)}
</div>
</div>
</>
)}
{!metrics && !error && host && <p style={{ fontSize: '12px', color: TEXT_SECONDARY }}>Loading metrics</p>}
</div>
</div>
)
}