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
251 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|