269 lines
10 KiB
JavaScript
269 lines
10 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import api from '../api/client'
|
|
import StatusBadge from '../components/StatusBadge'
|
|
import {
|
|
Cpu,
|
|
HardDrive,
|
|
MemoryStick,
|
|
Network,
|
|
Clock,
|
|
Shield,
|
|
ArrowLeft,
|
|
Terminal,
|
|
Download,
|
|
} from 'lucide-react'
|
|
|
|
export default function AgentDetail() {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const [data, setData] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const reload = () => {
|
|
api.getAgent(id).then(setData).finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
reload()
|
|
const interval = setInterval(reload, 30000)
|
|
return () => clearInterval(interval)
|
|
}, [id])
|
|
|
|
if (loading) return <div className="text-gray-500">Laden...</div>
|
|
if (!data) return <div className="text-red-400">Agent nicht gefunden</div>
|
|
|
|
const { agent, system_data: sys, status } = data
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</button>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-xl font-bold text-white">{agent.name}</h1>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<div className="text-sm text-gray-500 mt-0.5">
|
|
{agent.ip} — {agent.opnsense_version || 'Version unbekannt'}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<ActionButton icon={Download} label="Backup" onClick={() => api.triggerBackup(id)} />
|
|
<ActionButton icon={Terminal} label="Shell" onClick={() => {}} />
|
|
</div>
|
|
</div>
|
|
|
|
{!sys ? (
|
|
<div className="text-gray-500">Noch keine Systemdaten vorhanden</div>
|
|
) : (
|
|
<>
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<QuickStat
|
|
icon={Cpu}
|
|
label="CPU"
|
|
value={sys.cpu ? `${sys.cpu.usage_percent?.toFixed(1)}%` : '—'}
|
|
sub={sys.cpu?.model || ''}
|
|
/>
|
|
<QuickStat
|
|
icon={MemoryStick}
|
|
label="RAM"
|
|
value={sys.memory ? formatBytes(sys.memory.used_bytes) : '—'}
|
|
sub={sys.memory ? `von ${formatBytes(sys.memory.total_bytes)}` : ''}
|
|
/>
|
|
<QuickStat
|
|
icon={HardDrive}
|
|
label="Disks"
|
|
value={sys.disks ? `${sys.disks.length} Datasets` : '—'}
|
|
sub=""
|
|
/>
|
|
<QuickStat
|
|
icon={Clock}
|
|
label="Uptime"
|
|
value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'}
|
|
sub=""
|
|
/>
|
|
</div>
|
|
|
|
{/* Network Interfaces */}
|
|
{sys.network_interfaces && sys.network_interfaces.length > 0 && (
|
|
<Section title="Netzwerk-Interfaces" icon={Network}>
|
|
<div className="divide-y divide-gray-800">
|
|
{sys.network_interfaces
|
|
.filter((i) => i.addresses && i.addresses.length > 0)
|
|
.map((iface) => (
|
|
<div key={iface.name} className="px-4 py-2 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-sm text-white font-mono">{iface.name}</span>
|
|
<span className={`ml-2 text-xs ${iface.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
|
|
{iface.status}
|
|
</span>
|
|
<div className="text-xs text-gray-500">
|
|
{iface.addresses?.filter((a) => !a.includes(':')).join(', ')}
|
|
</div>
|
|
</div>
|
|
<div className="text-right text-xs text-gray-500">
|
|
<div>RX: {formatBytes(iface.rx_bytes)}</div>
|
|
<div>TX: {formatBytes(iface.tx_bytes)}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
)}
|
|
|
|
{/* Services */}
|
|
{sys.services && sys.services.length > 0 && (
|
|
<Section title="Dienste" icon={Shield}>
|
|
<div className="px-4 py-2 flex flex-wrap gap-2">
|
|
{sys.services.map((svc) => (
|
|
<span
|
|
key={svc.name}
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
svc.running
|
|
? 'bg-green-900/30 text-green-400 border border-green-800/50'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700'
|
|
}`}
|
|
>
|
|
{svc.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
)}
|
|
|
|
{/* Gateways */}
|
|
{sys.gateways && sys.gateways.length > 0 && (
|
|
<Section title="Gateways">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
|
<th className="px-4 py-2 font-medium">Name</th>
|
|
<th className="px-4 py-2 font-medium">Gateway</th>
|
|
<th className="px-4 py-2 font-medium">Status</th>
|
|
<th className="px-4 py-2 font-medium">RTT</th>
|
|
<th className="px-4 py-2 font-medium">Loss</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800">
|
|
{sys.gateways.map((gw) => (
|
|
<tr key={gw.name}>
|
|
<td className="px-4 py-2 text-white">{gw.name}</td>
|
|
<td className="px-4 py-2 text-gray-400">{gw.gateway}</td>
|
|
<td className="px-4 py-2">
|
|
<span className={gw.status === 'none' ? 'text-green-400' : 'text-yellow-400'}>
|
|
{gw.status === 'none' ? 'OK' : gw.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-400">{gw.delay || '—'}</td>
|
|
<td className="px-4 py-2 text-gray-400">{gw.loss || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Section>
|
|
)}
|
|
|
|
{/* Disks */}
|
|
{sys.disks && sys.disks.length > 0 && (
|
|
<Section title="ZFS Datasets" icon={HardDrive}>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
|
<th className="px-4 py-2 font-medium">Dataset</th>
|
|
<th className="px-4 py-2 font-medium">Mountpoint</th>
|
|
<th className="px-4 py-2 font-medium">Belegt</th>
|
|
<th className="px-4 py-2 font-medium">Gesamt</th>
|
|
<th className="px-4 py-2 font-medium">%</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800">
|
|
{sys.disks.map((d) => {
|
|
const pct = d.total_bytes > 0 ? (d.used_bytes / d.total_bytes * 100) : 0
|
|
return (
|
|
<tr key={d.filesystem}>
|
|
<td className="px-4 py-2 text-white font-mono text-xs">{d.filesystem}</td>
|
|
<td className="px-4 py-2 text-gray-400 text-xs">{d.mount_point}</td>
|
|
<td className="px-4 py-2 text-gray-400">{formatBytes(d.used_bytes)}</td>
|
|
<td className="px-4 py-2 text-gray-400">{formatBytes(d.total_bytes)}</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-16 h-1.5 bg-gray-800 rounded overflow-hidden">
|
|
<div
|
|
className={`h-full rounded ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-gray-500">{pct.toFixed(0)}%</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</Section>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Section({ title, icon: Icon, children }) {
|
|
return (
|
|
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
|
<div className="px-4 py-2.5 border-b border-gray-800 flex items-center gap-2">
|
|
{Icon && <Icon className="w-4 h-4 text-gray-500" />}
|
|
<span className="text-sm font-medium text-gray-300">{title}</span>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActionButton({ icon: Icon, label, onClick }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded text-sm text-gray-300 transition-colors"
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function QuickStat({ icon: Icon, label, value, sub }) {
|
|
return (
|
|
<div className="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
|
|
<div className="flex items-center gap-2 text-gray-500 mb-1">
|
|
<Icon className="w-4 h-4" />
|
|
<span className="text-xs">{label}</span>
|
|
</div>
|
|
<div className="text-lg font-bold text-white">{value}</div>
|
|
{sub && <div className="text-xs text-gray-500 truncate">{sub}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes || bytes === 0) return '0 B'
|
|
const k = 1024
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
const d = Math.floor(seconds / 86400)
|
|
const h = Math.floor((seconds % 86400) / 3600)
|
|
if (d > 0) return `${d}d ${h}h`
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
return `${h}h ${m}m`
|
|
}
|