From 616a0cb6ac1ddb583089ef941e0df84f12b29cdb Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Sat, 28 Feb 2026 16:03:02 +0100 Subject: [PATCH] Frontend: Firewall-Tabelle mit CPU/RAM/Disk/Uptime/Gateways, sortierbar, Slide-in Detail-Panel mit Tabs (Uebersicht, Interfaces, Dienste, VPN, Routen, DHCP, Zertifikate, Backups) --- backend/api/handlers.go | 1 + backend/api/users.go | 25 ++ backend/db/users.go | 9 + frontend/src/components/AgentPanel.jsx | 497 +++++++++++++++++++++++++ frontend/src/pages/Agents.jsx | 288 ++++++++++---- 5 files changed, 747 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/AgentPanel.jsx diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 3a1e4b5..89eade8 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -50,6 +50,7 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { // User-Routes mux.HandleFunc("GET /api/v1/users", h.listUsers) mux.HandleFunc("POST /api/v1/users", h.createUser) + mux.HandleFunc("PUT /api/v1/users/{id}/password", h.changePassword) mux.HandleFunc("DELETE /api/v1/users/{id}", h.deleteUser) // WebSocket und Tunnel-Routes diff --git a/backend/api/users.go b/backend/api/users.go index e5d8096..6d1d5ce 100644 --- a/backend/api/users.go +++ b/backend/api/users.go @@ -39,6 +39,31 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, u) } +// PUT /api/v1/users/{id}/password +func (h *Handler) changePassword(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Password == "" { + writeError(w, http.StatusBadRequest, "password ist erforderlich") + return + } + if len(req.Password) < 6 { + writeError(w, http.StatusBadRequest, "Passwort muss mindestens 6 Zeichen lang sein") + return + } + if err := h.db.ChangePassword(id, req.Password); err != nil { + writeError(w, http.StatusInternalServerError, "Passwort aendern fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Passwort geaendert"}) +} + // DELETE /api/v1/users/{id} func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) diff --git a/backend/db/users.go b/backend/db/users.go index 0269c84..5eb37c7 100644 --- a/backend/db/users.go +++ b/backend/db/users.go @@ -73,6 +73,15 @@ func (d *Database) UpdateUserLastLogin(id int) error { return err } +func (d *Database) ChangePassword(id int, newPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = d.db.Exec("UPDATE users SET password_hash = $1 WHERE id = $2", string(hash), id) + return err +} + func (d *Database) DeleteUser(id int) (bool, error) { res, err := d.db.Exec("DELETE FROM users WHERE id = $1", id) if err != nil { diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx new file mode 100644 index 0000000..584ca12 --- /dev/null +++ b/frontend/src/components/AgentPanel.jsx @@ -0,0 +1,497 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' +import StatusBadge from './StatusBadge' +import { + X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, + Globe, Key, Download, Terminal, Wifi, +} from 'lucide-react' + +const tabs = [ + { id: 'overview', label: 'Uebersicht' }, + { id: 'interfaces', label: 'Interfaces' }, + { id: 'services', label: 'Dienste' }, + { id: 'vpn', label: 'VPN' }, + { id: 'routes', label: 'Routen' }, + { id: 'dhcp', label: 'DHCP' }, + { id: 'certs', label: 'Zertifikate' }, + { id: 'backups', label: 'Backups' }, +] + +export default function AgentPanel({ agentId, customers, onClose, onReload }) { + const [data, setData] = useState(null) + const [tab, setTab] = useState('overview') + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + setTab('overview') + api.getAgent(agentId).then(setData).finally(() => setLoading(false)) + }, [agentId]) + + useEffect(() => { + if (!data) return + const iv = setInterval(() => { + api.getAgent(agentId).then(setData) + }, 30000) + return () => clearInterval(iv) + }, [agentId, data]) + + if (loading) return
Laden...
+ if (!data) return
Nicht gefunden
+ + const { agent, system_data: sys, status } = data + const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null + + return ( + + {/* Header */} +
+
+
+
+

{agent.name}

+ +
+ {cust && ( +
{cust.number} — {cust.name}
+ )} +
+ {agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'} +
+
+ +
+ + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+
+ + {/* Content */} +
+ {!sys ? ( +
Keine Systemdaten
+ ) : tab === 'overview' ? ( + + ) : tab === 'interfaces' ? ( + + ) : tab === 'services' ? ( + + ) : tab === 'vpn' ? ( + + ) : tab === 'routes' ? ( + + ) : tab === 'dhcp' ? ( + + ) : tab === 'certs' ? ( + + ) : tab === 'backups' ? ( + + ) : null} +
+
+ ) +} + +function Panel({ onClose, children }) { + return ( + <> +
+
+ {children} +
+ + + ) +} + +function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) { + return ( +
+
+ {Icon && } + {label} +
+
{value}
+ {sub &&
{sub}
} + {bar !== undefined && bar !== null && ( +
+
+
+ )} +
+ ) +} + +function OverviewTab({ sys }) { + const rootDisk = sys.disks?.find((d) => d.mount_point === '/') + const diskPct = rootDisk && rootDisk.total_bytes > 0 + ? (rootDisk.used_bytes / rootDisk.total_bytes * 100) : null + const ramPct = sys.memory?.total_bytes > 0 + ? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null + const cpuPct = sys.cpu?.usage_percent + + const barColor = (v) => v > 90 ? 'bg-red-500' : v > 70 ? 'bg-yellow-500' : v > 50 ? 'bg-orange-500' : 'bg-green-500' + + const ifaceCount = sys.network_interfaces?.filter((i) => i.status === 'active' || i.status === 'up').length || 0 + const gwCount = sys.gateways?.length || 0 + const wgCount = sys.wireguard_peers?.length || 0 + const routeCount = sys.routes?.length || 0 + const certCount = sys.certificates?.length || 0 + const svcRunning = sys.services?.filter((s) => s.running).length || 0 + const svcTotal = sys.services?.length || 0 + + return ( + <> +

System-Metriken

+
+ + + + +
+ +

Firewall Status

+
+ + + + + + + +
+ + {/* Gateways */} + {sys.gateways && sys.gateways.length > 0 && ( + <> +

Gateways

+
+ + + + + + + + + + + + {sys.gateways.map((gw) => ( + + + + + + + + ))} + +
NameGatewayStatusRTTLoss
{gw.name}{gw.gateway} + + {gw.status === 'none' ? 'OK' : gw.status} + + {gw.delay || '—'}{gw.loss || '—'}
+
+ + )} + + ) +} + +function StatusCard({ icon: Icon, value, label }) { + return ( +
+ {Icon && } +
{value}
+
{label}
+
+ ) +} + +function InterfacesTab({ sys }) { + const ifaces = sys.network_interfaces || [] + return ( +
+ + + + + + + + + + + + + {ifaces.map((i) => ( + + + + + + + + + ))} + +
InterfaceStatusMACAdressenRXTX
{i.name} + + {i.status || '—'} + + {i.mac || '—'} + {i.addresses && i.addresses.length > 0 + ? i.addresses.join(', ') + : '—'} + {formatBytes(i.rx_bytes)}{formatBytes(i.tx_bytes)}
+
+ ) +} + +function ServicesTab({ sys }) { + const services = sys.services || [] + const running = services.filter((s) => s.running) + const stopped = services.filter((s) => !s.running) + return ( +
+
{running.length} aktiv / {stopped.length} inaktiv
+
+ {services.map((svc) => ( + + {svc.name} + + ))} +
+
+ ) +} + +function VPNTab({ sys }) { + const peers = sys.wireguard_peers || [] + if (peers.length === 0) return
Keine WireGuard-Peers
+ return ( +
+ + + + + + + + + + + + {peers.map((p, i) => ( + + + + + + + + ))} + +
InterfacePublic KeyEndpointTransferLast Handshake
{p.interface || '—'}{(p.public_key || '—').substring(0, 16)}...{p.endpoint || '—'} + {p.transfer_rx ? `RX: ${formatBytes(p.transfer_rx)} / TX: ${formatBytes(p.transfer_tx)}` : '—'} + {p.latest_handshake || '—'}
+
+ ) +} + +function RoutesTab({ sys }) { + const routes = sys.routes || [] + if (routes.length === 0) return
Keine Routen
+ return ( +
+ + + + + + + + + + + {routes.map((r, i) => ( + + + + + + + ))} + +
ZielGatewayFlagsInterface
{r.destination}{r.gateway}{r.flags}{r.interface_name || r.iface}
+
+ ) +} + +function DHCPTab({ sys }) { + const leases = sys.dhcp_leases || [] + if (leases.length === 0) return
Keine DHCP Leases
+ return ( +
+ + + + + + + + + + + {leases.map((l, i) => ( + + + + + + + ))} + +
IPMACHostnameAblauf
{l.ip || l.address}{l.mac || l.hwaddr}{l.hostname || '—'}{l.expires || l.expire || '—'}
+
+ ) +} + +function CertsTab({ sys }) { + const certs = sys.certificates || [] + if (certs.length === 0) return
Keine Zertifikate
+ return ( +
+ + + + + + + + + + + {certs.map((c, i) => { + const expires = c.not_after ? new Date(c.not_after) : null + const isExpired = expires && expires < new Date() + const soonExpires = expires && !isExpired && (expires - new Date()) < 30 * 86400000 + return ( + + + + + + + ) + })} + +
NameAusstellerGueltig bisTyp
{c.name || c.subject || '—'}{c.issuer || '—'} + {expires ? expires.toLocaleDateString('de-DE') : '—'} + {c.type || (c.is_ca ? 'CA' : 'Cert')}
+
+ ) +} + +function BackupsTab({ agentId }) { + const [backups, setBackups] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.getBackups(agentId).then(setBackups).catch(() => {}).finally(() => setLoading(false)) + }, [agentId]) + + const triggerBackup = async () => { + await api.triggerBackup(agentId) + setTimeout(() => { + api.getBackups(agentId).then(setBackups) + }, 3000) + } + + if (loading) return
Laden...
+ return ( +
+ + {backups.length === 0 ? ( +
Keine Backups vorhanden
+ ) : ( +
+ + + + + + + + + + + {backups.map((b) => ( + + + + + + + ))} + +
IDErstelltGroesseHash
{b.id}{new Date(b.created_at).toLocaleString('de-DE')}{formatBytes(b.size)}{(b.hash || '').substring(0, 16)}...
+
+ )} +
+ ) +} + +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) + const m = Math.floor((seconds % 3600) / 60) + if (d > 0) return `${d}d ${h}h ${m}m` + return `${h}h ${m}m` +} diff --git a/frontend/src/pages/Agents.jsx b/frontend/src/pages/Agents.jsx index a9bf080..5e5f69b 100644 --- a/frontend/src/pages/Agents.jsx +++ b/frontend/src/pages/Agents.jsx @@ -1,109 +1,220 @@ -import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { useEffect, useState, useMemo } from 'react' import api from '../api/client' import StatusBadge from '../components/StatusBadge' -import { Search } from 'lucide-react' +import AgentPanel from '../components/AgentPanel' +import { Search, ChevronUp, ChevronDown } from 'lucide-react' export default function Agents() { const [agents, setAgents] = useState([]) const [customers, setCustomers] = useState([]) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) + const [sortKey, setSortKey] = useState('customer') + const [sortDir, setSortDir] = useState('asc') + const [selectedId, setSelectedId] = useState(null) - useEffect(() => { + const reload = () => { Promise.all([api.getAgents(), api.getCustomers()]) .then(([a, c]) => { setAgents(a || []) setCustomers(c || []) }) .finally(() => setLoading(false)) + } + + useEffect(() => { + reload() + const iv = setInterval(reload, 30000) + return () => clearInterval(iv) }, []) - const customerMap = {} - customers.forEach((c) => (customerMap[c.id] = c)) + const customerMap = useMemo(() => { + const m = {} + customers.forEach((c) => (m[c.id] = c)) + return m + }, [customers]) - const filtered = agents.filter((a) => { - const q = search.toLowerCase() - const cust = a.customer_id ? customerMap[a.customer_id] : null - return ( - a.name.toLowerCase().includes(q) || - a.ip.toLowerCase().includes(q) || - a.hostname.toLowerCase().includes(q) || - (cust && (cust.name.toLowerCase().includes(q) || cust.number.toLowerCase().includes(q))) - ) - }) + // Enrich agents with system data summaries + const [agentDetails, setAgentDetails] = useState({}) + + useEffect(() => { + agents.forEach((a) => { + if (!agentDetails[a.id]) { + api.getAgent(a.id).then((d) => { + setAgentDetails((prev) => ({ ...prev, [a.id]: d })) + }) + } + }) + }, [agents]) + + const toggleSort = (key) => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } else { + setSortKey(key) + setSortDir('asc') + } + } + + const enriched = useMemo(() => { + return agents.map((a) => { + const detail = agentDetails[a.id] + const sys = detail?.system_data + const cust = a.customer_id ? customerMap[a.customer_id] : null + const rootDisk = sys?.disks?.find((d) => d.mount_point === '/') + const diskPct = rootDisk && rootDisk.total_bytes > 0 + ? (rootDisk.used_bytes / rootDisk.total_bytes * 100) + : null + const ramPct = sys?.memory?.total_bytes > 0 + ? (sys.memory.used_bytes / sys.memory.total_bytes * 100) + : null + const gateways = sys?.gateways || [] + + return { + ...a, + customer: cust, + customerName: cust?.name || '', + customerNumber: cust?.number || '', + cpuPct: sys?.cpu?.usage_percent ?? null, + ramPct, + diskPct, + uptime: sys?.uptime_seconds || 0, + version: sys?.opnsense_version || a.opnsense_version || '', + gateways, + sys, + } + }) + }, [agents, agentDetails, customerMap]) + + const filtered = useMemo(() => { + let list = enriched + if (search) { + const q = search.toLowerCase() + list = list.filter((a) => + a.name.toLowerCase().includes(q) || + a.ip.toLowerCase().includes(q) || + a.hostname.toLowerCase().includes(q) || + a.customerName.toLowerCase().includes(q) || + a.customerNumber.toLowerCase().includes(q) + ) + } + list.sort((a, b) => { + let va, vb + switch (sortKey) { + case 'customer': va = a.customerNumber; vb = b.customerNumber; break + case 'name': va = a.name; vb = b.name; break + case 'ip': va = a.ip; vb = b.ip; break + case 'version': va = a.version; vb = b.version; break + case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime + case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1) + case 'ram': return sortDir === 'asc' ? (a.ramPct ?? 999) - (b.ramPct ?? 999) : (b.ramPct ?? -1) - (a.ramPct ?? -1) + case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1) + case 'status': va = a.status; vb = b.status; break + default: va = a.name; vb = b.name + } + if (typeof va === 'string') { + const cmp = va.localeCompare(vb) + return sortDir === 'asc' ? cmp : -cmp + } + return 0 + }) + return list + }, [enriched, search, sortKey, sortDir]) + + const SortHeader = ({ label, k, className = '' }) => ( + toggleSort(k)} + > + + {label} + {sortKey === k && (sortDir === 'asc' ? : )} + + + ) return (
-

Firewalls

- {agents.length} gesamt +

Firewalls ({agents.length})

- {/* Search */} -
- - setSearch(e.target.value)} - className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" - /> +
+
+ + setSearch(e.target.value)} + className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" + /> +
{loading ? (
Laden...
) : ( -
- +
+
- - - - - - - + + + + + + + + + + + + + - {filtered.map((agent) => { - const cust = agent.customer_id ? customerMap[agent.customer_id] : null - return ( - - - - - - - - - ) - })} + {filtered.map((a) => ( + setSelectedId(a.id)} + className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`} + > + + + + + + + + + + + + + + ))}
StatusNameIPVersionKundeLetzter Heartbeat
HOSTNAMEWAN IPLAST RESPONSEGATEWAYS
- - - - {agent.name} - - {agent.ip} - {agent.opnsense_version || '—'} - - {cust ? ( - - {cust.number} {cust.name} - - ) : ( - - )} - - {agent.last_heartbeat - ? new Date(agent.last_heartbeat).toLocaleString('de-DE') - : '—'} -
+ {a.customer ? {a.customerNumber} : } + {a.name}{a.hostname}{a.version || '—'}{a.ip}{a.uptime ? formatUptime(a.uptime) : '—'} + + + + + + + {a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'} + + {a.gateways.length > 0 ? ( +
+ {a.gateways.map((gw) => ( + + + {gw.name} + + ))} +
+ ) : '—'} +
{filtered.length === 0 && ( @@ -113,6 +224,37 @@ export default function Agents() { )}
)} + + {/* Detail Panel */} + {selectedId && ( + setSelectedId(null)} + onReload={reload} + /> + )}
) } + +function MiniBar({ value }) { + if (value === null || value === undefined) return + const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500' + return ( +
+
+
+
+ {value.toFixed(0)}% +
+ ) +} + +function formatUptime(seconds) { + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + if (d > 0) return `${d}d ${h}h` + return `${h}h ${m}m` +}