dev_arc_aws/src/pages/HostMetrics.tsx
Claude f32d93947b
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
2026-06-19 15:38:30 +00:00

251 lines
11 KiB
TypeScript

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