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:
parent
c37ad3d0d4
commit
f32d93947b
19 changed files with 881 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
37
backend/src/routes/metrics.ts
Normal file
37
backend/src/routes/metrics.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 }))
|
||||||
|
|
||||||
|
|
|
||||||
39
backend/src/ssh/metrics/common.ts
Normal file
39
backend/src/ssh/metrics/common.ts
Normal 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))
|
||||||
|
}
|
||||||
54
backend/src/ssh/metrics/cpu.ts
Normal file
54
backend/src/ssh/metrics/cpu.ts
Normal 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 }
|
||||||
|
}
|
||||||
44
backend/src/ssh/metrics/disk.ts
Normal file
44
backend/src/ssh/metrics/disk.ts
Normal 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 }
|
||||||
|
}
|
||||||
74
backend/src/ssh/metrics/firewall.ts
Normal file
74
backend/src/ssh/metrics/firewall.ts
Normal 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: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/ssh/metrics/index.ts
Normal file
31
backend/src/ssh/metrics/index.ts
Normal 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 }
|
||||||
|
}
|
||||||
74
backend/src/ssh/metrics/login-stats.ts
Normal file
74
backend/src/ssh/metrics/login-stats.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/ssh/metrics/memory.ts
Normal file
23
backend/src/ssh/metrics/memory.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/ssh/metrics/network.ts
Normal file
41
backend/src/ssh/metrics/network.ts
Normal 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 }
|
||||||
|
}
|
||||||
71
backend/src/ssh/metrics/ports.ts
Normal file
71
backend/src/ssh/metrics/ports.ts
Normal 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: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/ssh/metrics/processes.ts
Normal file
50
backend/src/ssh/metrics/processes.ts
Normal 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 }
|
||||||
|
}
|
||||||
21
backend/src/ssh/metrics/system.ts
Normal file
21
backend/src/ssh/metrics/system.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/ssh/metrics/uptime.ts
Normal file
18
backend/src/ssh/metrics/uptime.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
251
src/pages/HostMetrics.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue