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.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
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 { 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 }))
|
||||
|
||||
|
|
|
|||
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 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() {
|
|||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/containers" element={<Containers />} />
|
||||
<Route path="/remote-desktop" element={<RemoteDesktop />} />
|
||||
<Route path="/host-metrics" element={<HostMetrics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ force }),
|
||||
}),
|
||||
|
||||
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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