Frontend: React+Vite+Tailwind, Login, Dashboard, Agents, Customers, Settings, Dark Theme, Nginx auf .20
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
export default function Agents() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const customerMap = {}
|
||||
customers.forEach((c) => (customerMap[c.id] = c))
|
||||
|
||||
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)))
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Firewalls</h1>
|
||||
<span className="text-sm text-gray-500">{agents.length} gesamt</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Name, IP, Kunde)..."
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500">
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium hidden md:table-cell">IP</th>
|
||||
<th className="px-4 py-2 font-medium hidden lg:table-cell">Version</th>
|
||||
<th className="px-4 py-2 font-medium hidden lg:table-cell">Kunde</th>
|
||||
<th className="px-4 py-2 font-medium hidden md:table-cell">Letzter Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{filtered.map((agent) => {
|
||||
const cust = agent.customer_id ? customerMap[agent.customer_id] : null
|
||||
return (
|
||||
<tr
|
||||
key={agent.id}
|
||||
className="hover:bg-gray-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusBadge status={agent.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link to={`/agents/${agent.id}`} className="text-white hover:text-orange-400">
|
||||
{agent.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{agent.ip}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">
|
||||
{agent.opnsense_version || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 hidden lg:table-cell">
|
||||
{cust ? (
|
||||
<span className="text-gray-400">
|
||||
<span className="text-orange-400">{cust.number}</span> {cust.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
|
||||
{agent.last_heartbeat
|
||||
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { Plus, Trash2, Edit2, X, Check } from 'lucide-react'
|
||||
|
||||
export default function Customers() {
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [form, setForm] = useState({ number: '', name: '' })
|
||||
|
||||
const reload = () => {
|
||||
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { reload() }, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.number || !form.name) return
|
||||
await api.createCustomer(form.number, form.name)
|
||||
setForm({ number: '', name: '' })
|
||||
setShowAdd(false)
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleUpdate = async (id) => {
|
||||
await api.updateCustomer(id, form.number, form.name)
|
||||
setEditId(null)
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Kunde wirklich loeschen?')) return
|
||||
await api.deleteCustomer(id)
|
||||
reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Kunden</h1>
|
||||
<button
|
||||
onClick={() => { setShowAdd(true); setForm({ number: '', name: '' }) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neuer Kunde
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500">
|
||||
<th className="px-4 py-2 font-medium">Nummer</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
|
||||
<th className="px-4 py-2 font-medium w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{showAdd && (
|
||||
<tr>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="K05116"
|
||||
value={form.number}
|
||||
onChange={(e) => setForm({ ...form, number: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kundenname"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="hidden md:table-cell"></td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handleAdd} className="text-green-400 hover:text-green-300">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="text-gray-500 hover:text-gray-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{customers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-800/50">
|
||||
{editId === c.id ? (
|
||||
<>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.number}
|
||||
onChange={(e) => setForm({ ...form, number: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="hidden md:table-cell"></td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleUpdate(c.id)} className="text-green-400 hover:text-green-300">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setEditId(null)} className="text-gray-500 hover:text-gray-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-4 py-2 text-orange-400 font-mono">{c.number}</td>
|
||||
<td className="px-4 py-2 text-white">{c.name}</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs hidden md:table-cell">
|
||||
{new Date(c.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(c.id)} className="text-gray-500 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && customers.length === 0 && !showAdd && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Kunden angelegt</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { Server, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-500">Laden...</div>
|
||||
}
|
||||
|
||||
const online = agents.filter((a) => a.status === 'online').length
|
||||
const offline = agents.filter((a) => a.status === 'offline').length
|
||||
const stale = agents.filter((a) => a.status === 'stale').length
|
||||
const unassigned = agents.filter((a) => !a.customer_id).length
|
||||
|
||||
// Agents nach Kunde gruppieren
|
||||
const customerMap = {}
|
||||
customers.forEach((c) => {
|
||||
customerMap[c.id] = { ...c, agents: [] }
|
||||
})
|
||||
agents.forEach((a) => {
|
||||
if (a.customer_id && customerMap[a.customer_id]) {
|
||||
customerMap[a.customer_id].agents.push(a)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-white">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Server} label="Firewalls" value={agents.length} color="text-blue-400" />
|
||||
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
|
||||
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
|
||||
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
|
||||
</div>
|
||||
|
||||
{/* Agent-Liste nach Kunde */}
|
||||
<div className="space-y-4">
|
||||
{Object.values(customerMap)
|
||||
.filter((c) => c.agents.length > 0)
|
||||
.sort((a, b) => a.number.localeCompare(b.number))
|
||||
.map((customer) => (
|
||||
<div key={customer.id} className="bg-gray-900 rounded-lg border border-gray-800">
|
||||
<div className="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
<span className="text-orange-400">{customer.number}</span> — {customer.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800">
|
||||
{customer.agents.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Nicht zugewiesene Agents */}
|
||||
{unassigned > 0 && (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500">Nicht zugewiesen</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800">
|
||||
{agents
|
||||
.filter((a) => !a.customer_id)
|
||||
.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRow({ agent }) {
|
||||
const lastHB = agent.last_heartbeat
|
||||
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/agents/${agent.id}`}
|
||||
className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={agent.status} size="dot" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{agent.name}</div>
|
||||
<div className="text-xs text-gray-500">{agent.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-500">{agent.opnsense_version || '—'}</div>
|
||||
<div className="text-xs text-gray-600">{lastHB}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Login fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<Shield className="w-12 h-12 text-orange-500 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-white">RMM</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Remote Monitoring & Management</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-800 text-red-400 px-3 py-2 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Benutzer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:border-orange-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 text-white font-medium py-2 rounded transition-colors"
|
||||
>
|
||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [users, setUsers] = useState([])
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState({ username: '', password: '', display_name: '' })
|
||||
|
||||
const reload = () => api.getUsers().then((u) => setUsers(u || []))
|
||||
useEffect(() => { reload() }, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.username || !form.password) return
|
||||
await api.createUser(form)
|
||||
setForm({ username: '', password: '', display_name: '' })
|
||||
setShowAdd(false)
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Benutzer wirklich loeschen?')) return
|
||||
await api.deleteUser(id)
|
||||
reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-white">Einstellungen</h1>
|
||||
|
||||
{/* Users */}
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-300">Benutzer</span>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-1 text-sm text-orange-400 hover:text-orange-300"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Benutzer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleAdd} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
|
||||
Anlegen
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-gray-800">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-white">{u.username}</span>
|
||||
{u.display_name && (
|
||||
<span className="text-sm text-gray-500 ml-2">({u.display_name})</span>
|
||||
)}
|
||||
<span className="ml-2 text-xs text-gray-600">{u.role}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-600">
|
||||
{u.last_login ? `Letzter Login: ${new Date(u.last_login).toLocaleString('de-DE')}` : 'Nie eingeloggt'}
|
||||
</span>
|
||||
<button onClick={() => handleDelete(u.id)} className="text-gray-500 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user