rmm2/frontend/src/pages/AgentDetail.jsx

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