3033 lines
133 KiB
JavaScript
3033 lines
133 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import api from '../api/client'
|
|
import StatusBadge from './StatusBadge'
|
|
import { copyToClipboard } from '../utils/clipboard'
|
|
import { useSettingsStore } from '../stores/settings'
|
|
import TerminalModal from './TerminalModal'
|
|
import ScriptModal from './ScriptModal'
|
|
import {
|
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
|
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
|
|
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, Pencil, FileCode,
|
|
} from 'lucide-react'
|
|
|
|
// Two-level navigation structure (DATAZONE style)
|
|
const mainCategories = [
|
|
{ id: 'uebersicht', label: 'Uebersicht' },
|
|
{ id: 'netzwerk', label: 'Netzwerk' },
|
|
{ id: 'sicherheit', label: 'Sicherheit' },
|
|
{ id: 'daten', label: 'Daten' },
|
|
{ id: 'system', label: 'System' },
|
|
]
|
|
|
|
const subItems = {
|
|
netzwerk: [
|
|
{ id: 'interfaces', label: 'Interfaces', icon: Network },
|
|
{ id: 'vpn', label: 'VPN (WireGuard)', icon: Shield, platforms: ['freebsd'] },
|
|
{ id: 'routes', label: 'Routen', icon: GitBranch },
|
|
{ id: 'dhcp', label: 'DHCP', icon: Server, platforms: ['freebsd'] },
|
|
{ id: 'gateways', label: 'Gateways', icon: Globe },
|
|
],
|
|
sicherheit: [
|
|
{ id: 'certs', label: 'Zertifikate', icon: FileKey, platforms: ['freebsd'] },
|
|
{ id: 'security', label: 'Logins', icon: UserCheck, platforms: ['freebsd'] },
|
|
],
|
|
daten: [
|
|
{ id: 'backups', label: 'Backups', icon: Database },
|
|
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock, platforms: ['freebsd', 'linux'] },
|
|
{ id: 'tunnels', label: 'Tunnel', icon: Cable },
|
|
],
|
|
system: [
|
|
{ id: 'services', label: 'Dienste', icon: Settings },
|
|
{ id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd', 'linux'] },
|
|
{ id: 'agent', label: 'Agent', icon: Cpu },
|
|
],
|
|
}
|
|
|
|
export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
|
const [data, setData] = useState(null)
|
|
const [mainTab, setMainTab] = useState('uebersicht')
|
|
const [subTab, setSubTab] = useState('overview')
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setMainTab('uebersicht')
|
|
setSubTab('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 <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
|
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
|
|
|
const { agent, system_data: sys, status } = data
|
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
|
const platform = agent.platform || 'freebsd'
|
|
|
|
// Filter sub-items by platform and determine visible main categories
|
|
const getVisibleSubs = (catId) => {
|
|
const items = subItems[catId] || []
|
|
let filtered = items.filter(s => !s.platforms || s.platforms.includes(platform))
|
|
// Dynamic sub-tabs based on data availability
|
|
if (catId === 'netzwerk') {
|
|
if (sys?.haproxy) {
|
|
filtered = [...filtered, { id: 'haproxy', label: 'HAProxy', icon: Server }]
|
|
}
|
|
if (sys?.caddy) {
|
|
filtered = [...filtered, { id: 'caddy', label: 'Caddy', icon: Globe }]
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
const visibleMainTabs = mainCategories.filter(cat => {
|
|
if (cat.id === 'uebersicht') return true
|
|
return getVisibleSubs(cat.id).length > 0
|
|
})
|
|
|
|
const handleMainTabChange = (catId) => {
|
|
setMainTab(catId)
|
|
if (catId === 'uebersicht') {
|
|
setSubTab('overview')
|
|
} else {
|
|
const subs = getVisibleSubs(catId)
|
|
if (subs.length > 0) setSubTab(subs[0].id)
|
|
}
|
|
}
|
|
|
|
// For "gateways" sub-tab, we render the OverviewTab's gateway section standalone
|
|
const currentSubs = mainTab !== 'uebersicht' ? getVisibleSubs(mainTab) : []
|
|
|
|
const renderContent = () => {
|
|
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
|
|
const tab = subTab
|
|
if (tab === 'overview') return <OverviewTab sys={sys} agentId={agentId} />
|
|
if (tab === 'interfaces') return <InterfacesTab sys={sys} agentId={agentId} />
|
|
if (tab === 'services') return <ServicesTab sys={sys} />
|
|
if (tab === 'tunnels') return <TunnelTab agentId={agentId} agent={agent} />
|
|
if (tab === 'vpn') return <><VPNTab sys={sys} /><div className="mt-6"><WireGuardTab agentId={agentId} sys={sys} /></div></>
|
|
if (tab === 'routes') return <RoutesTab sys={sys} />
|
|
if (tab === 'dhcp') return <DHCPTab sys={sys} />
|
|
if (tab === 'gateways') return <GatewaysTab sys={sys} />
|
|
if (tab === 'certs') return <CertsTab sys={sys} />
|
|
if (tab === 'updates') return platform === 'linux' ? <LinuxUpdatesTab sys={sys} agentId={agentId} /> : <UpdatesTab agentId={agentId} />
|
|
if (tab === 'security') return <SecurityTab sys={sys} />
|
|
if (tab === 'tasks') return <TasksTab agentId={agentId} agentName={agent?.name || ''} platform={platform} />
|
|
if (tab === 'backups') return platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
|
if (tab === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
|
|
if (tab === 'haproxy') return <HAProxyTab sys={sys} />
|
|
if (tab === 'caddy') return <CaddyTab sys={sys} />
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Panel onClose={onClose}>
|
|
{/* Header */}
|
|
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{agent.hostname} · IP: {agent.ip} · {platform === 'linux' ? 'Linux' : 'OPNsense'} · {sys?.opnsense_version || agent.opnsense_version || '—'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
if (!confirm(`Geraet "${agent.name}" wirklich loeschen?`)) return
|
|
await api.deleteAgent(agent.id)
|
|
if (onReload) onReload()
|
|
onClose()
|
|
}}
|
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
|
title="Geraet loeschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Category Tabs */}
|
|
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
|
{visibleMainTabs.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => handleMainTabChange(cat.id)}
|
|
className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
|
|
mainTab === cat.id
|
|
? 'text-orange-400 border-orange-400'
|
|
: 'text-gray-400 hover:text-gray-200 border-transparent'
|
|
}`}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content area with optional sub-nav */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Sub-Navigation (left sidebar) — hidden for Uebersicht */}
|
|
{mainTab !== 'uebersicht' && currentSubs.length > 0 && (
|
|
<div className="w-44 shrink-0 bg-gray-900/50 border-r border-gray-800 overflow-y-auto py-3">
|
|
<div className="px-4 mb-2">
|
|
<span className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
|
|
{mainCategories.find(c => c.id === mainTab)?.label}
|
|
</span>
|
|
</div>
|
|
{currentSubs.map((item) => {
|
|
const Icon = item.icon
|
|
const isActive = subTab === item.id
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => setSubTab(item.id)}
|
|
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors ${
|
|
isActive
|
|
? 'text-orange-400 bg-orange-400/5 border-l-2 border-orange-400'
|
|
: 'text-gray-400 hover:text-white border-l-2 border-transparent'
|
|
}`}
|
|
>
|
|
{Icon && <Icon className="w-4 h-4 shrink-0" />}
|
|
<span>{item.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
function CustomerAssign({ agent, customers, current, onReload }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const handleChange = async (e) => {
|
|
const val = e.target.value
|
|
setSaving(true)
|
|
try {
|
|
if (val === '') {
|
|
await api.unassignCustomer(agent.id)
|
|
} else {
|
|
await api.assignCustomer(agent.id, parseInt(val))
|
|
}
|
|
if (onReload) onReload()
|
|
} catch (err) {
|
|
console.error(err)
|
|
} finally {
|
|
setSaving(false)
|
|
setEditing(false)
|
|
}
|
|
}
|
|
|
|
if (editing) {
|
|
return (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<select
|
|
value={agent.customer_id || ''}
|
|
onChange={handleChange}
|
|
disabled={saving}
|
|
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-orange-500"
|
|
autoFocus
|
|
onBlur={() => !saving && setEditing(false)}
|
|
>
|
|
<option value="">— Nicht zugewiesen —</option>
|
|
{customers.sort((a, b) => a.number.localeCompare(b.number)).map((c) => (
|
|
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
|
))}
|
|
</select>
|
|
{saving && <span className="text-xs text-gray-500">Speichere...</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="text-sm mt-0.5 cursor-pointer hover:underline"
|
|
onClick={() => setEditing(true)}
|
|
title="Kunde aendern"
|
|
>
|
|
{current ? (
|
|
<span className="text-orange-400">{current.number} — {current.name}</span>
|
|
) : (
|
|
<span className="text-gray-500 italic">Kein Kunde zugewiesen (klicken zum Zuweisen)</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Panel({ onClose, children }) {
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
|
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-6xl bg-gray-900 border-l border-gray-800 flex flex-col shadow-2xl animate-slide-in">
|
|
{children}
|
|
</div>
|
|
<style>{`
|
|
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
.animate-slide-in { animation: slideIn 0.2s ease-out; }
|
|
`}</style>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function GatewaysTab({ sys }) {
|
|
const gateways = sys.gateways || []
|
|
if (gateways.length === 0) return <div className="text-gray-500">Keine Gateways</div>
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Gateway</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">RTT</th>
|
|
<th className="px-3 py-2">Loss</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{gateways.map((gw) => (
|
|
<tr key={gw.name}>
|
|
<td className="px-3 py-2 text-white">{gw.name}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.gateway || gw.address}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={gwColor(gw.status)}>
|
|
{gw.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.delay || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.loss || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Sparkline({ data, height = 40, color = '#3b82f6' }) {
|
|
if (!data || data.length < 2) return null
|
|
const vals = data.map(d => d.value)
|
|
const min = Math.min(...vals)
|
|
const max = Math.max(...vals)
|
|
const range = max - min || 1
|
|
const w = 200
|
|
const h = height
|
|
const pts = vals.map((v, i) => {
|
|
const x = (i / (vals.length - 1)) * w
|
|
const y = h - ((v - min) / range) * (h - 4) - 2
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
}).join(' ')
|
|
return (
|
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
|
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function MetricCard({ icon: Icon, label, value, sub, bar, barColor, chart }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 text-gray-500 mb-2">
|
|
{Icon && <Icon className="w-4 h-4" />}
|
|
<span className="text-xs uppercase">{label}</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-white">{value}</div>
|
|
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
|
{bar !== undefined && bar !== null && (
|
|
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
|
|
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{chart && (
|
|
<div className="ml-3 opacity-70 flex-shrink-0">
|
|
{chart}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useMetricChart(agentId, metric) {
|
|
const [chartData, setChartData] = useState([])
|
|
useEffect(() => {
|
|
if (!agentId) return
|
|
const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString()
|
|
api.getMetrics(agentId, metric, `&from=${from}&limit=200`)
|
|
.then(data => { if (Array.isArray(data)) setChartData(data) })
|
|
.catch(() => {})
|
|
}, [agentId, metric])
|
|
return chartData
|
|
}
|
|
|
|
function PFStatesCard({ agentId, sys }) {
|
|
const chartData = useMetricChart(agentId, 'pf_states_current')
|
|
if (!sys?.pf_states) return null
|
|
const pf = sys.pf_states
|
|
const usedPct = pf.hard_limit > 0 ? (pf.current_entries / pf.hard_limit * 100) : 0
|
|
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
|
return (
|
|
<MetricCard
|
|
icon={Shield} label="PF States"
|
|
value={pf.hard_limit > 0
|
|
? `${pf.current_entries.toLocaleString('de-DE')} / ${pf.hard_limit.toLocaleString('de-DE')}`
|
|
: `${pf.current_entries.toLocaleString('de-DE')}`}
|
|
sub={`Search: ${pf.search_rate?.toFixed(1)}/s · Insert: ${pf.insert_rate?.toFixed(1)}/s`}
|
|
bar={pf.hard_limit > 0 ? usedPct : null} barColor={barColor}
|
|
chart={<Sparkline data={chartData} height={44} color="#3b82f6" />}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CpuCard({ agentId, sys }) {
|
|
const chartData = useMetricChart(agentId, 'cpu_usage')
|
|
const cpuPct = sys?.cpu?.usage_percent
|
|
const barColor = cpuPct > 90 ? 'bg-red-500' : cpuPct > 70 ? 'bg-yellow-500' : cpuPct > 50 ? 'bg-orange-500' : 'bg-green-500'
|
|
return (
|
|
<MetricCard
|
|
icon={Cpu} label="CPU Auslastung"
|
|
value={cpuPct != null ? `${cpuPct.toFixed(1)}%` : '—'}
|
|
sub={sys?.cpu?.model} bar={cpuPct} barColor={barColor}
|
|
chart={<Sparkline data={chartData} height={44} color="#f97316" />}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RamCard({ agentId, sys }) {
|
|
const chartData = useMetricChart(agentId, 'memory_used_percent')
|
|
const ramPct = sys?.memory?.total_bytes > 0
|
|
? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null
|
|
const barColor = ramPct > 90 ? 'bg-red-500' : ramPct > 70 ? 'bg-yellow-500' : ramPct > 50 ? 'bg-orange-500' : 'bg-green-500'
|
|
return (
|
|
<MetricCard
|
|
icon={MemoryStick} label="RAM Auslastung"
|
|
value={ramPct != null ? `${ramPct.toFixed(1)}%` : '—'}
|
|
sub={sys?.memory ? `${formatBytes(sys.memory.used_bytes)} / ${formatBytes(sys.memory.total_bytes)}` : ''}
|
|
bar={ramPct} barColor={barColor}
|
|
chart={<Sparkline data={chartData} height={44} color="#a855f7" />}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function OverviewTab({ sys, agentId }) {
|
|
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) => ['active', 'up'].includes(i.status)).length || 0
|
|
const gwCount = sys.gateways?.length || 0
|
|
const wgCount = (sys.wireguard || []).reduce((n, wg) => n + (wg.peers?.length || 0), 0) || 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 || s.status === 'running').length || 0
|
|
const svcTotal = sys.services?.length || 0
|
|
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">System-Metriken</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<CpuCard agentId={agentId} sys={sys} />
|
|
<RamCard agentId={agentId} sys={sys} />
|
|
<MetricCard icon={HardDrive} label="Festplatte" value={diskPct != null ? `${diskPct.toFixed(1)}%` : '—'}
|
|
sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''}
|
|
bar={diskPct} barColor={barColor(diskPct || 0)} />
|
|
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
|
<PFStatesCard agentId={agentId} sys={sys} />
|
|
</div>
|
|
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">System Status</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatusCard icon={Shield} value={sys.opnsense_version || '—'} label="OPNsense" />
|
|
<StatusCard icon={Network} value={ifaceCount} label="Interfaces" />
|
|
<StatusCard icon={Globe} value={gwCount} label="Gateways" />
|
|
<StatusCard icon={Wifi} value={wgCount} label="VPN Tunnel" />
|
|
<StatusCard icon={Route} value={routeCount} label="Routen" />
|
|
<StatusCard icon={Key} value={certCount} label="Zertifikate" />
|
|
<StatusCard value={`${svcRunning}/${svcTotal}`} label="Dienste" />
|
|
</div>
|
|
|
|
{/* Lizenz */}
|
|
{sys.license && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Lizenz</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-white font-medium">{sys.license.product_name || 'OPNsense'}</div>
|
|
{sys.license.subscription && (
|
|
<div className="text-xs text-gray-500 mt-1 font-mono">{sys.license.subscription}</div>
|
|
)}
|
|
</div>
|
|
{sys.license.valid_to && (
|
|
<div className="text-right">
|
|
<div className={`text-lg font-bold ${
|
|
sys.license.days_remaining < 30 ? 'text-red-400' :
|
|
sys.license.days_remaining < 90 ? 'text-yellow-400' :
|
|
'text-green-400'
|
|
}`}>
|
|
{sys.license.days_remaining > 0 ? `${sys.license.days_remaining} Tage` : 'Abgelaufen'}
|
|
</div>
|
|
<div className="text-xs text-gray-500">gueltig bis {sys.license.valid_to}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{sys.license.valid_to && sys.license.days_remaining != null && (
|
|
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
|
<div
|
|
className={`h-full rounded ${
|
|
sys.license.days_remaining < 30 ? 'bg-red-500' :
|
|
sys.license.days_remaining < 90 ? 'bg-yellow-500' :
|
|
'bg-green-500'
|
|
}`}
|
|
style={{ width: `${Math.min(100, Math.max(2, sys.license.days_remaining / 365 * 100))}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Gateways */}
|
|
{sys.gateways && sys.gateways.length > 0 && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Gateways</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Gateway</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">RTT</th>
|
|
<th className="px-3 py-2">Loss</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sys.gateways.map((gw) => (
|
|
<tr key={gw.name}>
|
|
<td className="px-3 py-2 text-white">{gw.name}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.gateway || gw.address}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={gwColor(gw.status)}>
|
|
{gw.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.delay || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{gw.loss || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function StatusCard({ icon: Icon, value, label }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
{Icon && <Icon className="w-5 h-5 mx-auto text-gray-500 mb-1" />}
|
|
<div className="text-lg font-bold text-white">{value}</div>
|
|
<div className="text-xs text-gray-500">{label}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const IFACE_RANGES = [
|
|
{ label: '8h', hours: 8, limit: 200 },
|
|
{ label: '24h', hours: 24, limit: 400 },
|
|
{ label: '7d', hours: 24 * 7, limit: 500 },
|
|
]
|
|
|
|
function IfaceChartSingle({ agentId, ifaceName, range }) {
|
|
const [rxData, setRxData] = useState([])
|
|
const [txData, setTxData] = useState([])
|
|
|
|
useEffect(() => {
|
|
if (!agentId || !ifaceName) return
|
|
setRxData([])
|
|
setTxData([])
|
|
const from = new Date(Date.now() - range.hours * 60 * 60 * 1000).toISOString()
|
|
const params = `&from=${from}&limit=${range.limit}&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}`
|
|
api.getMetrics(agentId, 'interface_rx_bps', params).then(d => { if (Array.isArray(d)) setRxData(d) }).catch(() => {})
|
|
api.getMetrics(agentId, 'interface_tx_bps', params).then(d => { if (Array.isArray(d)) setTxData(d) }).catch(() => {})
|
|
}, [agentId, ifaceName, range])
|
|
|
|
const formatBps = (v) => {
|
|
if (v >= 1024 * 1024 * 1024) return `${(v / 1024 / 1024 / 1024).toFixed(1)} GB/s`
|
|
if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`
|
|
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`
|
|
return `${Math.round(v)} B/s`
|
|
}
|
|
|
|
if (rxData.length < 2 && txData.length < 2) {
|
|
return (
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-gray-500 mb-1">{range.label}</span>
|
|
<div className="text-xs text-gray-600 py-3 text-center bg-gray-800/30 rounded">Keine Daten</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)]
|
|
const maxVal = Math.max(...allVals, 1)
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(maxVal)))
|
|
const niceMax = Math.ceil(maxVal / magnitude) * magnitude
|
|
|
|
const PAD_LEFT = 56
|
|
const PAD_RIGHT = 8
|
|
const PAD_TOP = 8
|
|
const PAD_BOTTOM = 22
|
|
const SVG_W = 600
|
|
const SVG_H = 130
|
|
const chartW = SVG_W - PAD_LEFT - PAD_RIGHT
|
|
const chartH = SVG_H - PAD_TOP - PAD_BOTTOM
|
|
|
|
const toX = (i, len) => PAD_LEFT + (i / Math.max(len - 1, 1)) * chartW
|
|
const toY = (val) => PAD_TOP + chartH - (val / niceMax) * chartH
|
|
const toPath = (data) => data.map((d, i) => `${toX(i, data.length).toFixed(1)},${toY(d.value).toFixed(1)}`).join(' ')
|
|
|
|
const yTicks = [0, 0.5, 1.0].map(f => ({ val: niceMax * f, y: toY(niceMax * f) }))
|
|
|
|
const fmtLabel = (iso) => {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
if (range.hours > 24) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
const allData = rxData.length >= txData.length ? rxData : txData
|
|
const xTicks = allData.length >= 2
|
|
? [
|
|
{ label: fmtLabel(allData[0]?.time), x: toX(0, allData.length) },
|
|
{ label: fmtLabel(allData[Math.floor(allData.length / 2)]?.time), x: toX(Math.floor(allData.length / 2), allData.length) },
|
|
{ label: fmtLabel(allData.at(-1)?.time), x: toX(allData.length - 1, allData.length) },
|
|
]
|
|
: []
|
|
|
|
const lastRx = rxData.at(-1)?.value || 0
|
|
const lastTx = txData.at(-1)?.value || 0
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-xs text-gray-500 font-medium">{range.label}</span>
|
|
<div className="flex gap-3 text-xs">
|
|
<span className="text-blue-400">↓ {formatBps(lastRx)}</span>
|
|
<span className="text-green-400">↑ {formatBps(lastTx)}</span>
|
|
</div>
|
|
</div>
|
|
<svg width="100%" viewBox={`0 0 ${SVG_W} ${SVG_H}`} preserveAspectRatio="xMidYMid meet" className="w-full" style={{ height: '130px' }}>
|
|
{yTicks.map(({ val, y }) => (
|
|
<g key={val}>
|
|
<line x1={PAD_LEFT} y1={y.toFixed(1)} x2={SVG_W - PAD_RIGHT} y2={y.toFixed(1)} stroke="#374151" strokeWidth="0.5" strokeDasharray={val === 0 ? 'none' : '3,3'} />
|
|
<text x={PAD_LEFT - 4} y={y + 4} textAnchor="end" fontSize="10" fill="#6b7280">{formatBps(val)}</text>
|
|
</g>
|
|
))}
|
|
{rxData.length >= 2 && <polygon points={[`${toX(0, rxData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`, ...rxData.map((d, i) => `${toX(i, rxData.length).toFixed(1)},${toY(d.value).toFixed(1)}`), `${toX(rxData.length - 1, rxData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`].join(' ')} fill="#3b82f620" />}
|
|
{txData.length >= 2 && <polygon points={[`${toX(0, txData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`, ...txData.map((d, i) => `${toX(i, txData.length).toFixed(1)},${toY(d.value).toFixed(1)}`), `${toX(txData.length - 1, txData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`].join(' ')} fill="#22c55e18" />}
|
|
{rxData.length >= 2 && <polyline points={toPath(rxData)} fill="none" stroke="#3b82f6" strokeWidth="1.5" strokeLinejoin="round" />}
|
|
{txData.length >= 2 && <polyline points={toPath(txData)} fill="none" stroke="#22c55e" strokeWidth="1.5" strokeLinejoin="round" />}
|
|
<line x1={PAD_LEFT} y1={PAD_TOP + chartH} x2={SVG_W - PAD_RIGHT} y2={PAD_TOP + chartH} stroke="#374151" strokeWidth="0.5" />
|
|
{xTicks.map(({ label, x }, idx) => (
|
|
<text key={idx} x={x} y={SVG_H - 4} textAnchor={idx === 0 ? 'start' : idx === xTicks.length - 1 ? 'end' : 'middle'} fontSize="10" fill="#6b7280">{label}</text>
|
|
))}
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IfaceChart({ agentId, ifaceName }) {
|
|
return (
|
|
<div className="mt-3 pt-3 border-t border-gray-700/50 space-y-4">
|
|
<div className="flex items-center gap-4 text-xs">
|
|
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-blue-400 rounded" /><span className="text-blue-400">RX</span></span>
|
|
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-green-400 rounded" /><span className="text-green-400">TX</span></span>
|
|
</div>
|
|
{IFACE_RANGES.map(r => (
|
|
<IfaceChartSingle key={r.label} agentId={agentId} ifaceName={ifaceName} range={r} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function fmtTime(iso) {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function InterfacesTab({ sys, agentId }) {
|
|
const [expanded, setExpanded] = useState(null)
|
|
const ifaces = sys.network_interfaces || []
|
|
return (
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-gray-300">Netzwerk-Interfaces</h3>
|
|
{ifaces.map((i) => {
|
|
const isUp = i.status === 'active' || i.status === 'up'
|
|
const ip = i.ip || i.addresses?.filter((a) => !a.includes(':')).join(', ') || ''
|
|
const role = i.role || i.description || ''
|
|
const isOpen = expanded === i.name
|
|
return (
|
|
<div
|
|
key={i.name}
|
|
className="bg-gray-800/50 rounded-lg border border-gray-700/50 px-5 py-4 cursor-pointer hover:border-gray-600/70 transition-colors"
|
|
onClick={() => setExpanded(isOpen ? null : i.name)}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
{/* Icon */}
|
|
<div className="flex-shrink-0">
|
|
<Network className={`w-7 h-7 ${isUp ? 'text-blue-400' : 'text-gray-600'}`} />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-white font-bold text-sm">{i.name}</span>
|
|
{role && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300 border border-blue-800/50">
|
|
{role}
|
|
</span>
|
|
)}
|
|
{!isUp && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
|
{i.status || 'down'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-400 mt-1 flex items-center gap-3">
|
|
{ip && <span>{ip}</span>}
|
|
{i.mac && <span className="text-gray-500 font-mono">{i.mac}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Traffic + Chevron */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0 text-right text-xs">
|
|
<div className="text-gray-400">
|
|
<span className="text-blue-400">↓</span> {formatBytes(i.rx_bytes)}
|
|
</div>
|
|
<div className="text-gray-400 mt-0.5">
|
|
<span className="text-green-400">↑</span> {formatBytes(i.tx_bytes)}
|
|
</div>
|
|
</div>
|
|
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500" /> : <ChevronRight className="w-4 h-4 text-gray-500" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expandierter Chart */}
|
|
{isOpen && <IfaceChart agentId={agentId} ifaceName={i.name} />}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SERVICE_DESCRIPTIONS = {
|
|
configd: 'Configuration Daemon',
|
|
dhcpd: 'DHCP Server',
|
|
dpinger: 'Gateway Monitor',
|
|
openssh: 'Secure Shell',
|
|
syslogd: 'System Logger',
|
|
unbound: 'DNS Resolver',
|
|
openvpn: 'OpenVPN',
|
|
cron: 'Cron Daemon',
|
|
ntpd: 'NTP Daemon',
|
|
'syslog-ng': 'Syslog-NG',
|
|
haproxy: 'HAProxy Load Balancer',
|
|
nginx: 'Nginx Webserver',
|
|
squid: 'Squid Proxy',
|
|
suricata: 'Suricata IDS/IPS',
|
|
wireguard: 'WireGuard VPN',
|
|
pf: 'Packet Filter',
|
|
routing: 'Routing Daemon',
|
|
radvd: 'Router Advertisement',
|
|
dnsmasq: 'DNS Forwarder',
|
|
monit: 'Process Monitor',
|
|
collectd: 'System Statistics',
|
|
rmm_agent: 'RMM Agent',
|
|
}
|
|
|
|
function isRunning(svc) {
|
|
return svc.running || svc.status === 'running'
|
|
}
|
|
|
|
function ServicesTab({ sys }) {
|
|
const services = sys.services || []
|
|
const running = services.filter(isRunning)
|
|
const stopped = services.filter((s) => !isRunning(s))
|
|
const sorted = [...services].sort((a, b) =>
|
|
isRunning(a) === isRunning(b) ? a.name.localeCompare(b.name) : isRunning(a) ? -1 : 1
|
|
)
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="text-sm text-gray-400">{running.length} aktiv / {stopped.length} inaktiv</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-4 py-2 font-medium">Dienst</th>
|
|
<th className="px-4 py-2 font-medium">Beschreibung</th>
|
|
<th className="px-4 py-2 font-medium text-right">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sorted.map((svc) => {
|
|
const up = isRunning(svc)
|
|
return (
|
|
<tr key={svc.name}>
|
|
<td className="px-4 py-2.5 text-white font-medium">{svc.name}</td>
|
|
<td className="px-4 py-2.5 text-gray-400">{svc.description || SERVICE_DESCRIPTIONS[svc.name] || '—'}</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1 rounded ${
|
|
up
|
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700'
|
|
}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${up ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{up ? 'running' : 'stopped'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VPNTab({ sys }) {
|
|
// Flatten: wireguard is array of interfaces, each with peers
|
|
const wgInterfaces = sys.wireguard || []
|
|
const allPeers = []
|
|
wgInterfaces.forEach((wg) => {
|
|
(wg.peers || []).forEach((p) => {
|
|
allPeers.push({ ...p, iface: wg.interface, listen_port: wg.listen_port, iface_pubkey: wg.public_key })
|
|
})
|
|
})
|
|
// Fallback for flat wireguard_peers
|
|
const peers = allPeers.length > 0 ? allPeers : (sys.wireguard_peers || [])
|
|
|
|
if (peers.length === 0) return <div className="text-gray-500">Keine WireGuard-Peers</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
{wgInterfaces.length > 0 && (
|
|
<div className="text-sm text-gray-400">{wgInterfaces.length} Interface(s), {peers.length} Peer(s)</div>
|
|
)}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Interface</th>
|
|
<th className="px-3 py-2">Public Key</th>
|
|
<th className="px-3 py-2">Endpoint</th>
|
|
<th className="px-3 py-2">Allowed IPs</th>
|
|
<th className="px-3 py-2">Transfer</th>
|
|
<th className="px-3 py-2">Handshake</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{peers.map((p, i) => {
|
|
const isActive = p.status === 'active' || p.handshake_age
|
|
return (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{p.iface || p.interface || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{(p.public_key || '—').substring(0, 16)}...</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{p.endpoint || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{(p.allowed_ips || []).join(', ') || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">
|
|
{p.transfer_rx_bytes || p.transfer_rx
|
|
? `↓ ${formatBytes(p.transfer_rx_bytes || p.transfer_rx)} / ↑ ${formatBytes(p.transfer_tx_bytes || p.transfer_tx)}`
|
|
: '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{p.handshake_age || p.latest_handshake || '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded ${
|
|
isActive
|
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{isActive ? 'aktiv' : 'inaktiv'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TunnelTab({ agentId, agent }) {
|
|
const [tunnels, setTunnels] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [creating, setCreating] = useState(null) // track which quick-button is active
|
|
const [customOpen, setCustomOpen] = useState(false)
|
|
const [customHost, setCustomHost] = useState('127.0.0.1')
|
|
const [customPort, setCustomPort] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [sshCopied, setSshCopied] = useState(null)
|
|
const [terminalOpen, setTerminalOpen] = useState(false)
|
|
const [scriptOpen, setScriptOpen] = useState(false)
|
|
|
|
const backendHost = useSettingsStore.getState().getBackendHost()
|
|
|
|
const loadTunnels = () => {
|
|
api.getTunnels(agentId)
|
|
.then((resp) => {
|
|
const list = resp?.tunnels || resp?.data?.tunnels || resp?.data || resp || []
|
|
setTunnels(Array.isArray(list) ? list : [])
|
|
})
|
|
.catch(() => setTunnels([]))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => { loadTunnels() }, [agentId])
|
|
useEffect(() => {
|
|
if (tunnels.length > 0) {
|
|
const iv = setInterval(loadTunnels, 10000)
|
|
return () => clearInterval(iv)
|
|
}
|
|
}, [tunnels.length, agentId])
|
|
|
|
const openTunnel = async (targetHost, targetPort, label) => {
|
|
setError('')
|
|
setCreating(label)
|
|
try {
|
|
const resp = await api.openTunnel(agentId, targetHost, targetPort)
|
|
const data = resp?.data || resp
|
|
// Reload tunnel list
|
|
loadTunnels()
|
|
// Auto-open for known services
|
|
if (data?.proxy_port) {
|
|
setTimeout(() => {
|
|
if (targetPort === 4444) {
|
|
window.open(`https://${backendHost}:${data.proxy_port}`, '_blank')
|
|
} else if (targetPort === 22) {
|
|
const cmd = `ssh root@${backendHost} -p ${data.proxy_port}`
|
|
copyToClipboard(cmd)
|
|
setSshCopied(data.proxy_port)
|
|
setTimeout(() => setSshCopied(null), 4000)
|
|
}
|
|
}, 1000)
|
|
}
|
|
return data
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setCreating(null)
|
|
}
|
|
}
|
|
|
|
const closeTunnel = async (tunnelId) => {
|
|
try {
|
|
await api.closeTunnel(agentId, tunnelId)
|
|
setTunnels((prev) => prev.filter((t) => t.id !== tunnelId && t.tunnel_id !== tunnelId))
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
const handleCustom = () => {
|
|
const port = parseInt(customPort)
|
|
if (!port || port < 1 || port > 65535) return
|
|
openTunnel(customHost, port, 'custom')
|
|
setCustomOpen(false)
|
|
setCustomPort('')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{terminalOpen && (
|
|
<TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} />
|
|
)}
|
|
{scriptOpen && (
|
|
<ScriptModal agentId={agentId} agentName={agent?.name} onClose={() => setScriptOpen(false)} />
|
|
)}
|
|
|
|
{/* Quick Buttons */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
|
<button
|
|
onClick={() => openTunnel('127.0.0.1', 4444, 'webgui')}
|
|
disabled={creating === 'webgui'}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<MonitorSmartphone className="w-4 h-4" />
|
|
{creating === 'webgui' ? 'Verbinde...' : 'WebGUI (4444)'}
|
|
</button>
|
|
<button
|
|
onClick={() => openTunnel('127.0.0.1', 22, 'ssh')}
|
|
disabled={creating === 'ssh'}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
{creating === 'ssh' ? 'Verbinde...' : 'SSH (22)'}
|
|
</button>
|
|
<button
|
|
onClick={() => setTerminalOpen(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-700 hover:bg-green-600 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
Web Terminal
|
|
</button>
|
|
<button
|
|
onClick={() => setScriptOpen(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-700 hover:bg-purple-600 rounded text-sm text-white transition-colors"
|
|
>
|
|
<FileCode className="w-4 h-4" />
|
|
Script
|
|
</button>
|
|
<button
|
|
onClick={() => setCustomOpen(!customOpen)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Benutzerdefiniert
|
|
</button>
|
|
</div>
|
|
|
|
{/* Custom Tunnel Form */}
|
|
{customOpen && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 flex items-end gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Ziel-Host</label>
|
|
<input
|
|
type="text"
|
|
value={customHost}
|
|
onChange={(e) => setCustomHost(e.target.value)}
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white w-40 focus:outline-none focus:border-orange-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Port</label>
|
|
<input
|
|
type="number"
|
|
value={customPort}
|
|
onChange={(e) => setCustomPort(e.target.value)}
|
|
placeholder="z.B. 8080"
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white w-28 focus:outline-none focus:border-orange-500"
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCustom()}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleCustom}
|
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm text-white transition-colors"
|
|
>
|
|
Verbinden
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{sshCopied && (
|
|
<div className="text-sm text-green-400 bg-green-900/20 rounded px-3 py-2 border border-green-800/50 flex items-center gap-2">
|
|
<Terminal className="w-4 h-4" />
|
|
SSH-Befehl kopiert — in CMD/Terminal einfuegen und ausfuehren
|
|
</div>
|
|
)}
|
|
{error && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
|
|
|
{/* Active Tunnels */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Aktive Tunnel</h3>
|
|
{loading ? (
|
|
<div className="text-gray-500">Laden...</div>
|
|
) : tunnels.length === 0 ? (
|
|
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">
|
|
Keine aktiven Tunnel
|
|
</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Ziel</th>
|
|
<th className="px-3 py-2">Lokaler Zugang</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Erstellt</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{tunnels.map((t) => {
|
|
const tid = t.id || t.tunnel_id
|
|
const proxyPort = t.proxy_port || t.local_port
|
|
const target = t.target_host && t.target_port
|
|
? `${t.target_host}:${t.target_port}`
|
|
: t.target || '—'
|
|
const proxyUrl = proxyPort ? `${backendHost}:${proxyPort}` : '—'
|
|
const isWebGUI = t.target_port === 4444 || t.target_port === '4444'
|
|
const isSSH = t.target_port === 22 || t.target_port === '22'
|
|
|
|
return (
|
|
<tr key={tid}>
|
|
<td className="px-3 py-2.5 text-white font-mono text-xs">{target}</td>
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-400 font-mono text-xs">{proxyUrl}</span>
|
|
{isWebGUI && proxyPort && (
|
|
<a
|
|
href={`https://${backendHost}:${proxyPort}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="text-orange-400 hover:text-orange-300"
|
|
title="WebGUI oeffnen"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
)}
|
|
{isSSH && proxyPort && (
|
|
<button
|
|
onClick={() => {
|
|
const cmd = `ssh root@${backendHost} -p ${proxyPort}`
|
|
copyToClipboard(cmd)
|
|
setSshCopied(proxyPort)
|
|
setTimeout(() => setSshCopied(null), 3000)
|
|
}}
|
|
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded transition-colors ${
|
|
sshCopied === proxyPort
|
|
? 'bg-green-900/30 text-green-400 border border-green-700/50'
|
|
: 'bg-blue-900/30 text-blue-400 border border-blue-700/50 hover:bg-blue-900/50'
|
|
}`}
|
|
title={`ssh root@${backendHost} -p ${proxyPort}`}
|
|
>
|
|
<Terminal className="w-3 h-3" />
|
|
{sshCopied === proxyPort ? 'Kopiert!' : 'SSH kopieren'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-green-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
|
|
aktiv
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-500 text-xs">
|
|
{t.created_at ? new Date(t.created_at).toLocaleString('de-DE') : '—'}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right">
|
|
<button
|
|
onClick={() => closeTunnel(tid)}
|
|
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
|
|
title="Tunnel schliessen"
|
|
>
|
|
<Unplug className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hint */}
|
|
<div className="text-xs text-gray-600 mt-2">
|
|
Tunnel werden ueber den RMM-Backend-Server ({backendHost}) geroutet. WebGUI-Tunnel oeffnen automatisch ein neues Browserfenster.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WireGuardTab({ agentId, sys }) {
|
|
const [peers, setPeers] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [adding, setAdding] = useState(false)
|
|
const [newPeer, setNewPeer] = useState({ name: '', dns: '' })
|
|
const [result, setResult] = useState(null)
|
|
const [copied, setCopied] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [noInstance, setNoInstance] = useState(false)
|
|
|
|
const loadPeers = () => {
|
|
setLoading(true)
|
|
setNoInstance(false)
|
|
api.getWgPeers(agentId)
|
|
.then((resp) => {
|
|
const d = resp?.data || resp || {}
|
|
setPeers(Array.isArray(d.peers) ? d.peers : Array.isArray(d) ? d : [])
|
|
})
|
|
.catch((e) => {
|
|
setPeers([])
|
|
if (e.message && e.message.includes('nicht gefunden')) setNoInstance(true)
|
|
})
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => { loadPeers() }, [agentId])
|
|
|
|
const handleAdd = async () => {
|
|
if (!newPeer.name.trim()) return
|
|
setError('')
|
|
try {
|
|
const resp = await api.addWgPeer(agentId, {
|
|
instance: 'ALWAYSON_VPN',
|
|
name: newPeer.name.trim(),
|
|
dns: newPeer.dns.trim(),
|
|
})
|
|
setResult(resp?.data || resp)
|
|
setAdding(false)
|
|
setNewPeer({ name: '', dns: '' })
|
|
loadPeers()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (uuid, name) => {
|
|
if (!confirm(`Peer "${name || uuid}" wirklich loeschen?`)) return
|
|
try {
|
|
await api.deleteWgPeer(agentId, uuid)
|
|
setResult(null)
|
|
loadPeers()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
const copyConfig = (text) => {
|
|
copyToClipboard(text)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
if (noInstance) {
|
|
return <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf diesem Geraet gefunden.</div>
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-400">
|
|
ALWAYSON_VPN Peer-Verwaltung
|
|
</div>
|
|
<button
|
|
onClick={() => { setAdding(!adding); setResult(null); setError('') }}
|
|
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 Peer
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Peer Form */}
|
|
{adding && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Name / Beschreibung</label>
|
|
<input
|
|
type="text"
|
|
value={newPeer.name}
|
|
onChange={(e) => setNewPeer({ ...newPeer, name: e.target.value })}
|
|
placeholder="z.B. Laptop-Christian"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">DNS Server</label>
|
|
<input
|
|
type="text"
|
|
value={newPeer.dns}
|
|
onChange={(e) => setNewPeer({ ...newPeer, dns: e.target.value })}
|
|
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
|
/>
|
|
</div>
|
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleAdd}
|
|
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 rounded text-sm text-white transition-colors"
|
|
>
|
|
Anlegen
|
|
</button>
|
|
<button
|
|
onClick={() => { setAdding(false); setError('') }}
|
|
className="px-4 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* New Peer Result / Config */}
|
|
{result && result.client_config && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-green-700/50 p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-green-400 font-medium">Peer erstellt — Client-Konfiguration:</span>
|
|
<button
|
|
onClick={() => copyConfig(result.client_config)}
|
|
className="flex items-center gap-1.5 px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors"
|
|
>
|
|
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
{copied ? 'Kopiert' : 'Kopieren'}
|
|
</button>
|
|
</div>
|
|
<pre className="bg-gray-900 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{result.client_config}
|
|
</pre>
|
|
{result.assigned_ip && (
|
|
<div className="text-xs text-gray-400">Zugewiesene IP: <span className="text-white">{result.assigned_ip}</span></div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Peer List */}
|
|
{loading ? (
|
|
<div className="text-gray-500">Laden...</div>
|
|
) : peers.length === 0 ? (
|
|
<div className="text-gray-500">Keine Peers konfiguriert</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Public Key</th>
|
|
<th className="px-3 py-2">Tunnel IP</th>
|
|
<th className="px-3 py-2">Enabled</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{peers.map((p) => (
|
|
<tr key={p.uuid || p.public_key}>
|
|
<td className="px-3 py-2.5 text-white">{p.name || p.description || '—'}</td>
|
|
<td className="px-3 py-2.5 text-gray-400 font-mono text-xs">{(p.public_key || p.pubkey || '—').substring(0, 20)}...</td>
|
|
<td className="px-3 py-2.5 text-gray-400 font-mono text-xs">{p.tunnel_address || p.allowed_ips || '—'}</td>
|
|
<td className="px-3 py-2.5">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs ${p.enabled !== false ? 'text-green-400' : 'text-gray-500'}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${p.enabled !== false ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
{p.enabled !== false ? 'Ja' : 'Nein'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right">
|
|
<button
|
|
onClick={() => handleDelete(p.uuid, p.name || p.description)}
|
|
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
|
|
title="Peer loeschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RoutesTab({ sys }) {
|
|
const routes = sys.routes || []
|
|
if (routes.length === 0) return <div className="text-gray-500">Keine Routen</div>
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Ziel</th>
|
|
<th className="px-3 py-2">Gateway</th>
|
|
<th className="px-3 py-2">Flags</th>
|
|
<th className="px-3 py-2">Interface</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{routes.map((r, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{r.destination}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{r.gateway}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{r.flags}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{r.interface_name || r.iface}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DHCPTab({ sys }) {
|
|
const [dhcpSearch, setDhcpSearch] = useState('')
|
|
// dhcp can be { server, leases: [...] } or flat dhcp_leases
|
|
const dhcp = sys.dhcp || {}
|
|
const allLeases = dhcp.leases || sys.dhcp_leases || []
|
|
const server = dhcp.server || ''
|
|
|
|
const leases = dhcpSearch
|
|
? allLeases.filter((l) => {
|
|
const q = dhcpSearch.toLowerCase()
|
|
return (l.ip || l.address || '').toLowerCase().includes(q) ||
|
|
(l.mac || l.hwaddr || '').toLowerCase().includes(q) ||
|
|
(l.hostname || '').toLowerCase().includes(q)
|
|
})
|
|
: allLeases
|
|
|
|
if (allLeases.length === 0) return <div className="text-gray-500">Keine DHCP Leases</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
{server && <div className="text-sm text-gray-400">DHCP Server: <span className="text-white">{server.toUpperCase()}</span> — {allLeases.length} Lease(s)</div>}
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-2 w-4 h-4 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="IP, MAC oder Hostname..."
|
|
value={dhcpSearch}
|
|
onChange={(e) => setDhcpSearch(e.target.value)}
|
|
className="w-full bg-gray-800 border border-gray-700 rounded pl-10 pr-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">IP</th>
|
|
<th className="px-3 py-2">MAC</th>
|
|
<th className="px-3 py-2">Hostname</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Ablauf</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{leases.map((l, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{l.ip || l.address}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono text-xs">{l.mac || l.hwaddr}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{l.hostname || '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs ${l.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
|
|
{l.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{l.end || l.expires || l.expire || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CertsTab({ sys }) {
|
|
const certs = sys.certificates || []
|
|
if (certs.length === 0) return <div className="text-gray-500">Keine Zertifikate</div>
|
|
|
|
const now = new Date()
|
|
|
|
const remaining = (expires) => {
|
|
if (!expires) return null
|
|
const diff = expires - now
|
|
if (diff < 0) return { text: 'Abgelaufen', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 }
|
|
const days = Math.floor(diff / 86400000)
|
|
if (days > 365) {
|
|
const y = Math.floor(days / 365)
|
|
const m = Math.floor((days % 365) / 30)
|
|
return { text: `${y}J ${m}M`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: 100 }
|
|
}
|
|
if (days > 7) return { text: `${days} Tage`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: Math.min(100, days / 3.65) }
|
|
if (days > 0) return { text: `${days} Tage`, color: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', barColor: 'bg-yellow-500', pct: Math.min(100, days / 3.65) }
|
|
return { text: 'Heute', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 }
|
|
}
|
|
|
|
const sorted = [...certs].sort((a, b) => {
|
|
const ea = a.not_after ? new Date(a.not_after) : new Date('2099-01-01')
|
|
const eb = b.not_after ? new Date(b.not_after) : new Date('2099-01-01')
|
|
return ea - eb
|
|
})
|
|
|
|
const expiredCount = sorted.filter(c => c.not_after && new Date(c.not_after) < now).length
|
|
const soonCount = sorted.filter(c => {
|
|
if (!c.not_after) return false
|
|
const e = new Date(c.not_after)
|
|
return e >= now && (e - now) < 7 * 86400000
|
|
}).length
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary */}
|
|
<div className="flex gap-3">
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-white">{sorted.length}</div>
|
|
<div className="text-xs text-gray-500">Gesamt</div>
|
|
</div>
|
|
{expiredCount > 0 && (
|
|
<div className="bg-red-500/10 rounded-lg border border-red-500/30 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-red-400">{expiredCount}</div>
|
|
<div className="text-xs text-red-400/70">Abgelaufen</div>
|
|
</div>
|
|
)}
|
|
{soonCount > 0 && (
|
|
<div className="bg-yellow-500/10 rounded-lg border border-yellow-500/30 px-4 py-2 flex-1 text-center">
|
|
<div className="text-2xl font-bold text-yellow-400">{soonCount}</div>
|
|
<div className="text-xs text-yellow-400/70"><7 Tage</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Certificate Cards */}
|
|
<div className="space-y-2">
|
|
{sorted.map((c, i) => {
|
|
const expires = c.not_after ? new Date(c.not_after) : null
|
|
const r = remaining(expires)
|
|
const type = c.type || (c.is_ca ? 'CA' : 'Cert')
|
|
return (
|
|
<div key={i} className={`rounded border px-3 py-2 ${r ? r.bg : 'bg-gray-800/50 border-gray-700/50'}`}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0 flex-1 flex items-center gap-2">
|
|
<span className="text-white text-xs font-medium truncate">{c.name || c.subject || '--'}</span>
|
|
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-700/50 text-gray-500 shrink-0">{type}</span>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
{r ? (
|
|
<span className={`text-xs font-medium ${r.color}`}>{r.text}</span>
|
|
) : (
|
|
<span className="text-xs text-gray-600">--</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{r && r.pct !== undefined && (
|
|
<div className="mt-1 h-0.5 bg-gray-700/50 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full ${r.barColor}`} style={{ width: `${r.pct}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PVEBackupsTab({ sys }) {
|
|
const [monthOffset, setMonthOffset] = useState(0)
|
|
|
|
const backups = sys?.backups || {}
|
|
const jobs = backups.jobs || []
|
|
const tasks = backups.tasks || []
|
|
|
|
// Stats
|
|
const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length
|
|
const okTasks = tasks.filter(t => t.status === 'OK').length
|
|
const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length
|
|
const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null
|
|
|
|
// Naechster Lauf
|
|
const nextRuns = jobs.map(j => j.next_run).filter(Boolean).sort()
|
|
const nextRun = nextRuns[0] ? new Date(nextRuns[0] * 1000) : null
|
|
|
|
// Kalender
|
|
const now = new Date()
|
|
const calMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
|
|
const year = calMonth.getFullYear()
|
|
const month = calMonth.getMonth()
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7 // Mo=0
|
|
|
|
// Tasks nach Tag gruppieren
|
|
const tasksByDay = {}
|
|
tasks.forEach(t => {
|
|
const d = new Date(t.starttime * 1000)
|
|
if (d.getFullYear() === year && d.getMonth() === month) {
|
|
const day = d.getDate()
|
|
if (!tasksByDay[day]) tasksByDay[day] = []
|
|
tasksByDay[day].push(t)
|
|
}
|
|
})
|
|
|
|
// Geplante Tage (aus Schedule-Pattern)
|
|
const plannedDays = new Set()
|
|
jobs.forEach(j => {
|
|
if (!j.enabled) return
|
|
const nr = j.next_run
|
|
if (nr) {
|
|
const d = new Date(nr * 1000)
|
|
if (d.getFullYear() === year && d.getMonth() === month) {
|
|
plannedDays.add(d.getDate())
|
|
}
|
|
}
|
|
})
|
|
|
|
const today = new Date()
|
|
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
|
|
|
|
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
|
const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
<div className="text-gray-500 text-xs mb-1">Jobs</div>
|
|
<div className="text-xl font-bold text-white">{totalJobs}</div>
|
|
</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
<div className="text-gray-500 text-xs mb-1">30 Tage</div>
|
|
<div className="text-xl font-bold text-white">{tasks.length}</div>
|
|
</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
<div className="text-gray-500 text-xs mb-1">Erfolgsrate</div>
|
|
<div className={`text-xl font-bold ${successRate === 100 ? 'text-emerald-400' : successRate === null ? 'text-gray-500' : successRate >= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
|
|
{successRate !== null ? `${successRate}%` : '--'}
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
<div className="text-gray-500 text-xs mb-1">Naechstes</div>
|
|
<div className="text-sm font-bold text-white">
|
|
{nextRun ? nextRun.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ', ' + nextRun.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '--'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Kalender */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button onClick={() => setMonthOffset(monthOffset - 1)} className="text-gray-400 hover:text-white px-2 py-1"><</button>
|
|
<h3 className="text-white font-semibold">{monthNames[month]} {year}</h3>
|
|
<button onClick={() => setMonthOffset(monthOffset + 1)} className="text-gray-400 hover:text-white px-2 py-1">></button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1 text-center">
|
|
{dayNames.map(d => (
|
|
<div key={d} className="text-gray-500 text-xs py-1">{d}</div>
|
|
))}
|
|
{/* Leere Zellen vor dem 1. */}
|
|
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
|
<div key={`empty-${i}`} />
|
|
))}
|
|
{/* Tage */}
|
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
|
const day = i + 1
|
|
const dayTasks = tasksByDay[day] || []
|
|
const isToday = isCurrentMonth && day === today.getDate()
|
|
const hasOk = dayTasks.some(t => t.status === 'OK')
|
|
const hasFailed = dayTasks.some(t => t.status && t.status !== 'OK')
|
|
const isPlanned = plannedDays.has(day)
|
|
|
|
return (
|
|
<div
|
|
key={day}
|
|
className={`relative py-2 rounded text-sm ${
|
|
isToday ? 'border border-emerald-500/50' :
|
|
'border border-transparent'
|
|
} ${hasOk ? 'bg-emerald-900/20' : hasFailed ? 'bg-red-900/20' : ''}`}
|
|
>
|
|
<span className={`${isToday ? 'text-emerald-400 font-bold' : 'text-gray-300'}`}>{day}</span>
|
|
{/* Dot indicators */}
|
|
<div className="flex justify-center gap-0.5 mt-0.5">
|
|
{hasOk && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
|
|
{hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-red-400" />}
|
|
{isPlanned && !hasOk && !hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Legende */}
|
|
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-400" /> OK</span>
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-400" /> Fehler</span>
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-400" /> Geplant</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Konfigurierte Jobs */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Konfigurierte Jobs</h3>
|
|
{jobs.length === 0 ? (
|
|
<div className="text-gray-500 text-sm">Keine Backup-Jobs konfiguriert</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{jobs.map((job, i) => {
|
|
const nr = job.next_run ? new Date(job.next_run * 1000) : null
|
|
const vmids = job.vmid || (job.all ? 'Alle' + (job.exclude ? ` (ohne ${job.exclude})` : '') : '--')
|
|
return (
|
|
<div key={i} className="bg-gray-900 rounded p-3 flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
|
<span className="text-white text-sm font-medium">{vmids}</span>
|
|
</div>
|
|
<div className="text-gray-500 text-xs mt-1">
|
|
{job.schedule} · {job.storage} · {job.mode}
|
|
{job.node && <span> · Node: {job.node}</span>}
|
|
</div>
|
|
</div>
|
|
{nr && (
|
|
<div className="text-right text-xs text-gray-400">
|
|
Naechstes: {nr.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}, {nr.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Letzte Backups */}
|
|
{tasks.length > 0 && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Letzte Backups ({tasks.length})</h3>
|
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
|
{tasks.slice(0, 30).map((t, i) => {
|
|
const start = new Date(t.starttime * 1000)
|
|
const duration = t.endtime && t.starttime ? Math.round((t.endtime - t.starttime) / 60) : null
|
|
return (
|
|
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-gray-800/50">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${t.status === 'OK' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
|
<span className="text-gray-300">
|
|
{start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{duration !== null && <span className="text-gray-500">{duration} min</span>}
|
|
<span className={`${t.status === 'OK' ? 'text-emerald-400' : 'text-red-400'}`}>{t.status}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SecurityTab({ sys }) {
|
|
const sec = sys?.security
|
|
if (!sec) return <div className="text-gray-500">Keine Sicherheitsdaten verfuegbar</div>
|
|
|
|
const summary = sec.summary || {}
|
|
const logins = sec.ssh_logins || []
|
|
const sessions = sec.sessions || []
|
|
|
|
const cards = [
|
|
{ label: 'Akzeptiert', value: summary.total_accepted || 0, color: 'text-green-400' },
|
|
{ label: 'Abgelehnt', value: summary.total_failed || 0, color: 'text-red-400' },
|
|
{ label: 'Eindeutige IPs', value: summary.unique_ips || 0, color: 'text-blue-400' },
|
|
{ label: 'Letzter Login', value: summary.last_login ? new Date(summary.last_login).toLocaleString('de-DE') : '-', color: 'text-gray-300', small: true },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{cards.map((c, i) => (
|
|
<div key={i} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3">
|
|
<div className="text-gray-500 text-xs mb-1">{c.label}</div>
|
|
<div className={`${c.small ? 'text-sm' : 'text-xl font-bold'} ${c.color}`}>{c.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* SSH Logins */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-400 mb-2">SSH-Logins</h3>
|
|
{logins.length === 0 ? (
|
|
<div className="text-gray-500 text-sm">Keine SSH-Login-Events</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-700">
|
|
<th className="px-3 py-2">Zeitstempel</th>
|
|
<th className="px-3 py-2">User</th>
|
|
<th className="px-3 py-2">Quell-IP</th>
|
|
<th className="px-3 py-2">Port</th>
|
|
<th className="px-3 py-2">Methode</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{logins.map((e, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-1.5 text-gray-400">{e.timestamp ? new Date(e.timestamp).toLocaleString('de-DE') : '-'}</td>
|
|
<td className="px-3 py-1.5 text-white">{e.user}</td>
|
|
<td className="px-3 py-1.5 text-gray-300 font-mono">{e.source}</td>
|
|
<td className="px-3 py-1.5 text-gray-400">{e.port}</td>
|
|
<td className="px-3 py-1.5">
|
|
<span className="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400 text-xs">{e.method}</span>
|
|
</td>
|
|
<td className="px-3 py-1.5">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className={`w-1.5 h-1.5 rounded-full ${e.status === 'accepted' ? 'bg-green-400' : 'bg-red-400'}`} />
|
|
<span className={e.status === 'accepted' ? 'text-green-400' : 'text-red-400'}>{e.status}</span>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sessions */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-400 mb-2">Sessions</h3>
|
|
{sessions.length === 0 ? (
|
|
<div className="text-gray-500 text-sm">Keine Sessions</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-700">
|
|
<th className="px-3 py-2">User</th>
|
|
<th className="px-3 py-2">TTY</th>
|
|
<th className="px-3 py-2">Von</th>
|
|
<th className="px-3 py-2">Login</th>
|
|
<th className="px-3 py-2">Logout</th>
|
|
<th className="px-3 py-2">Dauer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sessions.map((s, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-1.5 text-white">{s.user}</td>
|
|
<td className="px-3 py-1.5 text-gray-400 font-mono">{s.tty}</td>
|
|
<td className="px-3 py-1.5 text-gray-300 font-mono">{s.from || '-'}</td>
|
|
<td className="px-3 py-1.5 text-gray-400">{s.login}</td>
|
|
<td className="px-3 py-1.5 text-gray-400">{s.logout || '-'}</td>
|
|
<td className="px-3 py-1.5 text-gray-400">{s.duration || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TasksTab({ agentId, agentName, platform }) {
|
|
const [tasks, setTasks] = useState([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [showDialog, setShowDialog] = useState(false)
|
|
const [editTask, setEditTask] = useState(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [showWebhook, setShowWebhook] = useState(false)
|
|
const isLinux = platform === 'linux'
|
|
|
|
const defaultForm = {
|
|
name: '', action: isLinux ? 'update' : 'backup', schedule_type: 'once', schedule_time: '02:00',
|
|
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
|
|
reboot: false, health_check: false, health_check_timeout: 600,
|
|
security_only: false,
|
|
webhook_url: '', active: true,
|
|
}
|
|
|
|
const jobTemplatesFreeBSD = [
|
|
{
|
|
label: 'Wöchentliches Update',
|
|
form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly',
|
|
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true,
|
|
health_check_timeout: 600, active: true },
|
|
},
|
|
{
|
|
label: 'Monatliches Update',
|
|
form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly',
|
|
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
|
|
health_check_timeout: 600, active: true },
|
|
},
|
|
{
|
|
label: 'Tägliches Backup',
|
|
form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily',
|
|
schedule_time: '03:00', reboot: false, health_check: false, active: true },
|
|
},
|
|
{
|
|
label: 'Monatliches Backup',
|
|
form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly',
|
|
schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, active: true },
|
|
},
|
|
{
|
|
label: 'Update + ERP-Webhook',
|
|
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
|
|
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
|
|
health_check_timeout: 600, active: true },
|
|
showWebhook: true,
|
|
},
|
|
]
|
|
|
|
const jobTemplatesLinux = [
|
|
{
|
|
label: 'Wöchentlich alle Updates',
|
|
form: { name: 'Wöchentliches Debian-Update', action: 'update', schedule_type: 'weekly',
|
|
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true,
|
|
health_check_timeout: 600, security_only: false, active: true },
|
|
},
|
|
{
|
|
label: 'Monatlich alle Updates',
|
|
form: { name: 'Monatliches Debian-Update', action: 'update', schedule_type: 'monthly',
|
|
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
|
|
health_check_timeout: 600, security_only: false, active: true },
|
|
},
|
|
{
|
|
label: 'Wöchentlich nur Security',
|
|
form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly',
|
|
schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true,
|
|
health_check_timeout: 300, security_only: true, active: true },
|
|
},
|
|
{
|
|
label: 'Täglich Security (kein Reboot)',
|
|
form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily',
|
|
schedule_time: '04:00', reboot: false, health_check: false,
|
|
security_only: true, active: true },
|
|
},
|
|
{
|
|
label: 'Update + Webhook',
|
|
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
|
|
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
|
|
health_check_timeout: 600, security_only: false, active: true },
|
|
showWebhook: true,
|
|
},
|
|
]
|
|
|
|
const jobTemplates = isLinux ? jobTemplatesLinux : jobTemplatesFreeBSD
|
|
const [form, setForm] = useState(defaultForm)
|
|
|
|
const loadTasks = () => {
|
|
api.getTasks({ agent_id: agentId, limit: 50 })
|
|
.then(resp => { setTasks(resp.data || []); setTotal(resp.total || 0) })
|
|
.catch(() => setTasks([]))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => { loadTasks() }, [agentId])
|
|
useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId])
|
|
|
|
const handleCreate = async () => {
|
|
setCreating(true); setError('')
|
|
try {
|
|
const data = { agent_id: agentId, name: form.name, action: form.action, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active }
|
|
if (form.schedule_type === 'once' && form.scheduled_at) {
|
|
data.scheduled_at = new Date(form.scheduled_at).toISOString()
|
|
}
|
|
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
|
|
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
|
|
if (form.action === 'update') {
|
|
data.reboot = form.reboot; data.health_check = form.health_check
|
|
if (form.health_check) data.health_check_timeout = parseInt(form.health_check_timeout) || 600
|
|
if (isLinux) data.security_only = form.security_only || false
|
|
}
|
|
if (form.webhook_url) data.webhook_url = form.webhook_url
|
|
await api.createTask(data)
|
|
setShowDialog(false); setForm(defaultForm); setShowWebhook(false); loadTasks()
|
|
} catch (e) { setError(e.message) } finally { setCreating(false) }
|
|
}
|
|
|
|
const handleToggle = async (id) => {
|
|
try { await api.toggleTask(id); loadTasks() } catch (e) { setError(e.message) }
|
|
}
|
|
const handleDelete = async (id) => {
|
|
if (!confirm('Job wirklich loeschen?')) return
|
|
try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) }
|
|
}
|
|
const handleEdit = (t) => {
|
|
setEditTask(t)
|
|
setForm({
|
|
name: t.name || '',
|
|
action: t.action,
|
|
schedule_type: t.schedule_type || 'once',
|
|
schedule_time: t.schedule_time || '02:00',
|
|
scheduled_at: '',
|
|
schedule_weekday: t.schedule_weekday ?? 1,
|
|
schedule_monthday: t.schedule_monthday ?? 1,
|
|
reboot: t.reboot || false,
|
|
health_check: t.health_check || false,
|
|
health_check_timeout: t.health_check_timeout || 600,
|
|
webhook_url: t.webhook_url || '',
|
|
active: t.active !== false,
|
|
})
|
|
setShowWebhook(!!t.webhook_url)
|
|
setShowDialog(true)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setCreating(true); setError('')
|
|
try {
|
|
const data = {
|
|
name: form.name, schedule_type: form.schedule_type, schedule_time: form.schedule_time,
|
|
active: form.active, reboot: form.reboot, health_check: form.health_check,
|
|
health_check_timeout: parseInt(form.health_check_timeout) || 600,
|
|
webhook_url: form.webhook_url,
|
|
}
|
|
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
|
|
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
|
|
await api.updateTask(editTask.id, data)
|
|
setShowDialog(false); setEditTask(null); setForm(defaultForm); setShowWebhook(false); loadTasks()
|
|
} catch (e) { setError(e.message) } finally { setCreating(false) }
|
|
}
|
|
|
|
const handleRunNow = async (id) => {
|
|
try {
|
|
await api.runTaskNow(id)
|
|
setTimeout(loadTasks, 1500)
|
|
} catch (e) { setError(e.message) }
|
|
}
|
|
|
|
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
|
|
|
const fmtInterval = (t) => {
|
|
if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}`
|
|
if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}`
|
|
if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}`
|
|
return 'Einmalig'
|
|
}
|
|
|
|
const fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'
|
|
|
|
const inputCls = 'w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500'
|
|
const labelCls = 'block text-xs text-gray-400 mb-1'
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-400">{total} Job(s)</div>
|
|
<button onClick={() => setShowDialog(true)} 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 Job
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{showDialog && (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
|
|
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
|
<h3 className="text-white font-semibold">{editTask ? `Job bearbeiten — ${editTask.name || `#${editTask.id}`}` : `Neuer Job${agentName ? ` — ${agentName}` : ''}`}</h3>
|
|
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
|
|
</div>
|
|
{!editTask && (
|
|
<div className="px-5 pt-4 pb-2 border-b border-gray-700/50">
|
|
<p className="text-xs text-gray-500 mb-2">Vorlage wählen</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{jobTemplates.map((tpl, i) => (
|
|
<button key={i} onClick={() => {
|
|
setForm({ ...defaultForm, ...tpl.form, scheduled_at: '', schedule_weekday: tpl.form.schedule_weekday ?? 1, schedule_monthday: tpl.form.schedule_monthday ?? 1 })
|
|
setShowWebhook(!!tpl.showWebhook)
|
|
}} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 hover:text-white transition-colors">
|
|
{tpl.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="px-5 py-4 space-y-4">
|
|
<div>
|
|
<label className={labelCls}>Name *</label>
|
|
<input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className={labelCls}>Intervall *</label>
|
|
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls} disabled={!!editTask}>
|
|
<option value="once">Einmalig</option>
|
|
<option value="daily">Täglich</option>
|
|
<option value="weekly">Wöchentlich</option>
|
|
<option value="monthly">Monatlich</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Uhrzeit *</label>
|
|
<input type="time" value={form.schedule_time} onChange={e => setForm({...form, schedule_time: e.target.value})} className={inputCls} />
|
|
</div>
|
|
</div>
|
|
{form.schedule_type === 'once' && (
|
|
<div>
|
|
<label className={labelCls}>Datum</label>
|
|
<input type="datetime-local" value={form.scheduled_at} onChange={e => setForm({...form, scheduled_at: e.target.value})} className={inputCls} />
|
|
</div>
|
|
)}
|
|
{form.schedule_type === 'weekly' && (
|
|
<div>
|
|
<label className={labelCls}>Wochentag</label>
|
|
<select value={form.schedule_weekday} onChange={e => setForm({...form, schedule_weekday: e.target.value})} className={inputCls}>
|
|
{weekdays.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{form.schedule_type === 'monthly' && (
|
|
<div>
|
|
<label className={labelCls}>Tag im Monat</label>
|
|
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
|
|
</div>
|
|
)}
|
|
{!editTask && (
|
|
<div>
|
|
<label className={labelCls}>Aktion *</label>
|
|
<div className="flex gap-2">
|
|
{(isLinux ? ['update'] : ['backup', 'update']).map(a => (
|
|
<button key={a} onClick={() => setForm({...form, action: a})}
|
|
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
|
|
{a === 'backup' ? 'Backup' : 'apt-get upgrade'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{form.action === 'update' && (
|
|
<div className="space-y-2 pl-1">
|
|
{isLinux && (
|
|
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" checked={form.security_only || false} onChange={e => setForm({...form, security_only: e.target.checked})} className="accent-orange-500" />
|
|
Nur Security-Updates (apt-get upgrade mit security-Filter)
|
|
</label>
|
|
)}
|
|
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
|
|
Reboot nach Update
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" checked={form.health_check} onChange={e => setForm({...form, health_check: e.target.checked})} className="accent-orange-500" />
|
|
Health-Check nach Update
|
|
</label>
|
|
{form.health_check && (
|
|
<div className="pl-6">
|
|
<label className={labelCls}>Health-Check Timeout (Sekunden)</label>
|
|
<input type="number" value={form.health_check_timeout} onChange={e => setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[200px]'} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<button onClick={() => setShowWebhook(!showWebhook)} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-300">
|
|
{showWebhook ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Webhook-URL (optional)
|
|
</button>
|
|
{showWebhook && (
|
|
<div>
|
|
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://erp.example.com/api/service-reports" className={inputCls + ' mt-1'} />
|
|
<p className="text-xs text-gray-500 mt-1">Empfangt: customer_id, hostname, opnsense_version, reachable, update_status</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" checked={form.active} onChange={e => setForm({...form, active: e.target.checked})} className="accent-orange-500" />
|
|
Job ist aktiv
|
|
</label>
|
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
|
</div>
|
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
|
|
<button onClick={() => { setShowDialog(false); setEditTask(null); setError('') }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors">Abbrechen</button>
|
|
<button onClick={editTask ? handleSave : handleCreate} disabled={creating || !form.name} className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
|
|
{creating ? (editTask ? 'Speichere...' : 'Erstelle...') : (editTask ? 'Speichern' : 'Erstellen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
{loading ? (
|
|
<div className="text-gray-500">Laden...</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">Keine Jobs vorhanden</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2 w-8">Status</th>
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Aktion</th>
|
|
<th className="px-3 py-2">Intervall</th>
|
|
<th className="px-3 py-2">Nächster Lauf</th>
|
|
<th className="px-3 py-2">Letzter Lauf</th>
|
|
<th className="px-3 py-2">Webhook</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{tasks.map(t => (
|
|
<tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${t.active ? 'bg-green-500' : 'bg-gray-500'}`} title={t.active ? 'Aktiv' : 'Inaktiv'} />
|
|
{t.status === 'running' && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300">lauft</span>}
|
|
{t.status === 'failed' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">fehler</span>}
|
|
{t.status === 'completed_with_warning' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-900/50 text-yellow-300" title="Update OK, aber Agent nicht wieder online">warnung</span>}
|
|
{t.status === 'cancelled' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">abgebr.</span>}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-white">{t.name || `Job #${t.id}`}</td>
|
|
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.next_run)}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.last_run)}</td>
|
|
<td className="px-3 py-2 text-xs">
|
|
{t.webhook_url ? (
|
|
t.webhook_sent
|
|
? <span className="text-green-400" title={t.webhook_response || 'Gesendet'}>OK</span>
|
|
: t.last_run
|
|
? <span className="text-red-400" title={t.webhook_response || 'Fehlgeschlagen'}>Fehler</span>
|
|
: <span className="text-gray-500">—</span>
|
|
) : <span className="text-gray-600">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button onClick={() => handleEdit(t)} className="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700 transition-colors" title="Bearbeiten">
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleRunNow(t.id)} className="text-blue-400 hover:text-blue-300 p-1 rounded hover:bg-blue-900/30 transition-colors" title="Jetzt ausführen" disabled={t.status === 'running'}>
|
|
<Play className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleToggle(t.id)} className={`p-1 rounded transition-colors ${t.active ? 'text-green-400 hover:text-green-300 hover:bg-green-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-700'}`} title={t.active ? 'Deaktivieren' : 'Aktivieren'}>
|
|
<Power className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleDelete(t.id)} className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors" title="Löschen">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BackupsTab({ agentId }) {
|
|
const [backups, setBackups] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [creating, setCreating] = useState(false)
|
|
const [msg, setMsg] = useState('')
|
|
|
|
const loadBackups = () => {
|
|
api.getBackups(agentId)
|
|
.then((resp) => {
|
|
const list = Array.isArray(resp) ? resp : (resp?.data || resp?.backups || [])
|
|
setBackups(Array.isArray(list) ? list : [])
|
|
})
|
|
.catch(() => setBackups([]))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => { loadBackups() }, [agentId])
|
|
|
|
const triggerBackup = async () => {
|
|
setCreating(true)
|
|
setMsg('')
|
|
try {
|
|
const resp = await api.triggerBackup(agentId)
|
|
const data = resp?.data || resp
|
|
if (data?.new === false) {
|
|
setMsg('Config unveraendert (Hash identisch)')
|
|
} else {
|
|
setMsg('Backup erstellt')
|
|
}
|
|
setTimeout(loadBackups, 1000)
|
|
} catch (e) {
|
|
setMsg('Fehler: ' + e.message)
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="text-gray-500">Laden...</div>
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={triggerBackup}
|
|
disabled={creating}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{creating ? 'Erstelle...' : 'Backup jetzt erstellen'}
|
|
</button>
|
|
{msg && <span className={`text-sm ${msg.startsWith('Fehler') ? 'text-red-400' : 'text-gray-400'}`}>{msg}</span>}
|
|
</div>
|
|
{backups.length === 0 ? (
|
|
<div className="text-gray-500">Keine Backups vorhanden</div>
|
|
) : (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">ID</th>
|
|
<th className="px-3 py-2">Erstellt</th>
|
|
<th className="px-3 py-2">Groesse</th>
|
|
<th className="px-3 py-2">Hash</th>
|
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{backups.map((b) => (
|
|
<tr key={b.id}>
|
|
<td className="px-3 py-2 text-white">{b.id}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{new Date(b.created_at).toLocaleString('de-DE')}</td>
|
|
<td className="px-3 py-2 text-gray-400">{formatBytes(b.size)}</td>
|
|
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{(b.hash || '').substring(0, 16)}...</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<button
|
|
onClick={() => api.downloadBackup(agentId, b.id)}
|
|
className="text-orange-400 hover:text-orange-300 p-1 rounded hover:bg-orange-900/30 transition-colors"
|
|
title="config.xml herunterladen"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LinuxUpdatesTab({ sys, agentId }) {
|
|
const [filter, setFilter] = useState('all')
|
|
const [search, setSearch] = useState('')
|
|
const [running, setRunning] = useState(false)
|
|
const [runResult, setRunResult] = useState(null)
|
|
|
|
const updates = sys?.updates?.updates || []
|
|
const secCount = updates.filter(u => u.security).length
|
|
const regCount = updates.length - secCount
|
|
|
|
const visible = updates.filter(u => {
|
|
if (filter === 'security' && !u.security) return false
|
|
if (filter === 'regular' && u.security) return false
|
|
if (search) {
|
|
const q = search.toLowerCase()
|
|
return u.package?.toLowerCase().includes(q) || u.repository?.toLowerCase().includes(q)
|
|
}
|
|
return true
|
|
})
|
|
|
|
const runUpdate = async () => {
|
|
if (!confirm('Alle Updates jetzt installieren?')) return
|
|
setRunning(true)
|
|
setRunResult(null)
|
|
try {
|
|
const res = await api.runUpdate(agentId, false)
|
|
setRunResult(res.data || res)
|
|
} catch (err) {
|
|
setRunResult({ error: err.message })
|
|
} finally {
|
|
setRunning(false)
|
|
}
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-6 text-center">
|
|
<div className="w-10 h-10 rounded-full bg-emerald-900/40 flex items-center justify-center mx-auto mb-3">
|
|
<Download className="w-5 h-5 text-emerald-400" />
|
|
</div>
|
|
<p className="text-emerald-400 font-medium text-sm">System ist aktuell</p>
|
|
<p className="text-gray-500 text-xs mt-1">Keine ausstehenden Updates</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<h3 className="text-white font-semibold">Verfügbare Updates</h3>
|
|
{secCount > 0 && (
|
|
<span className="flex items-center gap-1 bg-red-900/50 border border-red-700/50 text-red-300 text-xs font-semibold px-2.5 py-1 rounded">
|
|
<Shield className="w-3 h-3" /> {secCount} Security
|
|
</span>
|
|
)}
|
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded border ${regCount > 0 ? 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300' : 'bg-gray-700/50 border-gray-600/50 text-gray-400'}`}>
|
|
{regCount} Regular
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={runUpdate}
|
|
disabled={running}
|
|
className="flex items-center gap-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-xs px-3 py-1.5 rounded transition-colors"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{running ? 'Läuft...' : 'Updates installieren'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter + Suche */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{[
|
|
{ id: 'all', label: `Alle (${updates.length})` },
|
|
{ id: 'security', label: `Security (${secCount})` },
|
|
{ id: 'regular', label: `Regular (${regCount})` },
|
|
].map(f => (
|
|
<button
|
|
key={f.id}
|
|
onClick={() => setFilter(f.id)}
|
|
className={`text-xs px-3 py-1.5 rounded transition-colors border ${
|
|
filter === f.id
|
|
? 'bg-blue-600 border-blue-500 text-white'
|
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
<div className="ml-auto flex items-center gap-2 bg-gray-800 border border-gray-700 rounded px-2 py-1">
|
|
<Search className="w-3.5 h-3.5 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Suchen..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="bg-transparent text-xs text-gray-300 outline-none w-40 placeholder-gray-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabelle */}
|
|
<div className="bg-gray-900/60 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-700/70 text-gray-500 uppercase tracking-wide">
|
|
<th className="text-left px-4 py-2.5">Paket</th>
|
|
<th className="text-left px-4 py-2.5">Aktuelle Version</th>
|
|
<th className="text-left px-4 py-2.5">Neue Version</th>
|
|
<th className="text-left px-4 py-2.5">Repository</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visible.map((pkg, i) => (
|
|
<tr
|
|
key={i}
|
|
className={`border-b border-gray-800/50 last:border-0 ${pkg.security ? 'bg-red-950/10' : ''}`}
|
|
>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
{pkg.security && <Shield className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
|
|
<span className="font-mono text-gray-200">{pkg.package}</span>
|
|
{pkg.security && (
|
|
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-red-900/60 border border-red-700/50 text-red-300">SEC</span>
|
|
)}
|
|
{pkg.arch && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/60 text-gray-400">{pkg.arch}</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-500 font-mono">{pkg.current_version || '—'}</td>
|
|
<td className="px-4 py-2 font-mono">
|
|
<span className="text-gray-400">→ </span>
|
|
<span className="text-emerald-400">{pkg.new_version}</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-500">{pkg.repository || '—'}</td>
|
|
</tr>
|
|
))}
|
|
{visible.length === 0 && (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-6 text-center text-gray-600">Keine Pakete gefunden</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Ergebnis */}
|
|
{runResult && (
|
|
<div className={`text-xs p-3 rounded border ${runResult.error ? 'bg-red-900/20 border-red-700/50 text-red-300' : 'bg-gray-800 border-gray-700 text-gray-300'}`}>
|
|
{runResult.error ? runResult.error : (runResult.output || runResult.message || JSON.stringify(runResult))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function UpdatesTab({ agentId }) {
|
|
const [checking, setChecking] = useState(false)
|
|
const [updating, setUpdating] = useState(false)
|
|
const [result, setResult] = useState(null)
|
|
const [updateResult, setUpdateResult] = useState(null)
|
|
const [reboot, setReboot] = useState(false)
|
|
const [majorVer, setMajorVer] = useState('')
|
|
const [majorPhase, setMajorPhase] = useState('1')
|
|
const [majorReboot, setMajorReboot] = useState(true)
|
|
|
|
const checkUpdates = async () => {
|
|
setChecking(true)
|
|
setResult(null)
|
|
try {
|
|
const res = await api.checkUpdates(agentId)
|
|
setResult(res.data || res)
|
|
} catch (err) {
|
|
setResult({ error: err.message })
|
|
} finally {
|
|
setChecking(false)
|
|
}
|
|
}
|
|
|
|
const runUpdate = async () => {
|
|
if (!confirm('Update jetzt ausfuehren?' + (reboot ? ' (mit Reboot)' : ''))) return
|
|
setUpdating(true)
|
|
setUpdateResult(null)
|
|
try {
|
|
const res = await api.runUpdate(agentId, reboot)
|
|
setUpdateResult(res.data || res)
|
|
} catch (err) {
|
|
setUpdateResult({ error: err.message })
|
|
} finally {
|
|
setUpdating(false)
|
|
}
|
|
}
|
|
|
|
const runMajorUpdate = async () => {
|
|
if (!majorVer) return
|
|
const phaseText = majorPhase === '1' ? 'Phase 1: Base+Kernel installieren' + (majorReboot ? ' + Reboot' : '') : 'Phase 2: Packages aktualisieren'
|
|
if (!confirm(`Major-Update auf ${majorVer}?\n${phaseText}`)) return
|
|
setUpdating(true)
|
|
setUpdateResult(null)
|
|
try {
|
|
const res = await api.post(`/api/v1/agents/${agentId}/major-update`, {
|
|
version: majorVer,
|
|
phase: majorPhase,
|
|
reboot: majorReboot
|
|
})
|
|
setUpdateResult(res.data || res)
|
|
} catch (err) {
|
|
setUpdateResult({ error: err.message })
|
|
} finally {
|
|
setUpdating(false)
|
|
}
|
|
}
|
|
|
|
const runReboot = async () => {
|
|
if (!confirm('Geraet jetzt neu starten?')) return
|
|
try {
|
|
await api.post(`/api/v1/agents/${agentId}/reboot`, { delay: 5 })
|
|
setUpdateResult({ message: 'Reboot in 5 Sekunden...' })
|
|
} catch (err) {
|
|
setUpdateResult({ error: err.message })
|
|
}
|
|
}
|
|
|
|
const pendingPkgs = result?.pending_packages || []
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Update Check */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-white font-semibold">Update-Check</h3>
|
|
<button
|
|
onClick={checkUpdates}
|
|
disabled={checking}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-3 py-1.5 rounded text-sm transition-colors"
|
|
>
|
|
<Search className="w-4 h-4" />
|
|
{checking ? 'Pruefe...' : 'Updates pruefen'}
|
|
</button>
|
|
</div>
|
|
|
|
{result?.error && (
|
|
<div className="text-red-400 text-sm bg-red-900/20 rounded p-2">{result.error}</div>
|
|
)}
|
|
|
|
{result && !result.error && (
|
|
<div className="space-y-3">
|
|
{/* Core Update */}
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${result.core_update_available ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
|
<span className="text-sm text-gray-300">
|
|
{result.core_update_available ? 'Core-Update verfuegbar' : 'Core ist aktuell'}
|
|
</span>
|
|
</div>
|
|
{result.core_update_info && result.core_update_available && (
|
|
<pre className="bg-gray-900 text-gray-400 text-xs p-2 rounded overflow-x-auto max-h-32">{result.core_update_info}</pre>
|
|
)}
|
|
|
|
{/* Reboot Required */}
|
|
{result.reboot_required && (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
|
<span className="text-sm text-red-400 font-medium">Reboot erforderlich</span>
|
|
<button onClick={runReboot} className="ml-auto text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
|
Jetzt neustarten
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Package Updates */}
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${pendingPkgs.length > 0 ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
|
<span className="text-sm text-gray-300">
|
|
{pendingPkgs.length > 0 ? `${pendingPkgs.length} Paket-Updates verfuegbar` : 'Alle Pakete aktuell'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Package List */}
|
|
{pendingPkgs.length > 0 && (
|
|
<div className="bg-gray-900 rounded overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-gray-500 border-b border-gray-800">
|
|
<th className="text-left px-3 py-1.5">Paket</th>
|
|
<th className="text-left px-3 py-1.5">Aktuell</th>
|
|
<th className="text-left px-3 py-1.5">Neu</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pendingPkgs.map((pkg, i) => (
|
|
<tr key={i} className="border-b border-gray-800/50">
|
|
<td className="px-3 py-1 text-gray-300 font-mono">{pkg.package}</td>
|
|
<td className="px-3 py-1 text-gray-500">{pkg.current_version}</td>
|
|
<td className="px-3 py-1 text-emerald-400">{pkg.new_version}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Normales Update */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-1">Normales Update</h3>
|
|
<p className="text-gray-500 text-xs mb-3">OPNsense Core + Paket-Updates installieren.</p>
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={runUpdate}
|
|
disabled={updating}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{updating ? 'Update laeuft...' : 'Update starten'}
|
|
</button>
|
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={reboot}
|
|
onChange={(e) => setReboot(e.target.checked)}
|
|
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
|
/>
|
|
Nach Update neustarten
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Major Update */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-1">Major-Update</h3>
|
|
<p className="text-gray-500 text-xs mb-3">
|
|
Upgrade auf neue OPNsense-Version (2 Phasen: Phase 1 = Base+Kernel + Reboot, Phase 2 = Packages).
|
|
</p>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<input
|
|
type="text"
|
|
value={majorVer}
|
|
onChange={(e) => setMajorVer(e.target.value)}
|
|
placeholder="z.B. 26.1"
|
|
className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 w-28 focus:border-orange-500 focus:outline-none"
|
|
/>
|
|
<select
|
|
value={majorPhase}
|
|
onChange={(e) => setMajorPhase(e.target.value)}
|
|
className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:border-orange-500 focus:outline-none"
|
|
>
|
|
<option value="1">Phase 1 (Base+Kernel)</option>
|
|
<option value="2">Phase 2 (Packages)</option>
|
|
</select>
|
|
{majorPhase === '1' && (
|
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={majorReboot}
|
|
onChange={(e) => setMajorReboot(e.target.checked)}
|
|
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
|
/>
|
|
Reboot
|
|
</label>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={runMajorUpdate}
|
|
disabled={updating || !majorVer}
|
|
className="flex items-center gap-2 bg-red-600 hover:bg-red-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
|
>
|
|
<Shield className="w-4 h-4" />
|
|
{updating ? 'Update laeuft...' : `Major-Update Phase ${majorPhase} starten`}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Reboot */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-1">Reboot</h3>
|
|
<p className="text-gray-500 text-xs mb-3">Geraet manuell neu starten.</p>
|
|
<button
|
|
onClick={runReboot}
|
|
className="flex items-center gap-2 bg-red-600/80 hover:bg-red-600 text-white px-4 py-2 rounded text-sm transition-colors"
|
|
>
|
|
Jetzt neustarten
|
|
</button>
|
|
</div>
|
|
|
|
{/* Update-Ergebnis */}
|
|
{updateResult && (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-2">Ergebnis</h3>
|
|
{updateResult.error ? (
|
|
<div className="text-red-400 text-sm">{updateResult.error}</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{updateResult.message && (
|
|
<div className="text-emerald-400 text-sm">{updateResult.message}</div>
|
|
)}
|
|
{updateResult.steps?.map((step, i) => (
|
|
<div key={i} className="bg-gray-900 rounded p-2">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<div className={`w-2 h-2 rounded-full ${step.ok ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
|
<span className="text-gray-300 text-xs font-mono">{step.step}</span>
|
|
</div>
|
|
{step.output && (
|
|
<pre className="text-gray-500 text-xs overflow-x-auto max-h-40 whitespace-pre-wrap">{step.output}</pre>
|
|
)}
|
|
{step.error && <div className="text-red-400 text-xs mt-1">{step.error}</div>}
|
|
</div>
|
|
))}
|
|
{updateResult.reboot_required && (
|
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
|
Reboot erforderlich
|
|
{!updateResult.reboot_requested && (
|
|
<button onClick={runReboot} className="ml-2 text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
|
Jetzt neustarten
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentTab({ agent, status, platform }) {
|
|
const [updating, setUpdating] = useState(false)
|
|
const [msg, setMsg] = useState('')
|
|
|
|
const requestUpdate = async () => {
|
|
setUpdating(true)
|
|
try {
|
|
await api.requestAgentUpdate(agent.id)
|
|
setMsg('Update angefordert — wird beim naechsten Updater-Zyklus ausgefuehrt')
|
|
} catch (err) {
|
|
setMsg(`Fehler: ${err.message}`)
|
|
} finally {
|
|
setUpdating(false)
|
|
}
|
|
}
|
|
|
|
const lastHB = agent?.last_heartbeat ? new Date(agent.last_heartbeat) : null
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Agent Update */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-1">Agent Update</h3>
|
|
<p className="text-gray-500 text-xs mb-3">Aktualisiert die Agent-Dateien remote ueber den bestehenden Agent-Kanal.</p>
|
|
<button
|
|
onClick={requestUpdate}
|
|
disabled={updating || agent?.update_requested}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{agent?.update_requested ? 'Update ausstehend...' : updating ? 'Wird angefordert...' : 'Agent aktualisieren'}
|
|
</button>
|
|
{msg && <div className="text-xs text-emerald-400 mt-2">{msg}</div>}
|
|
</div>
|
|
|
|
{/* Nuetzliche Befehle */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Nuetzliche Befehle</h3>
|
|
<div className="space-y-2">
|
|
{(platform === 'linux' ? [
|
|
{ label: 'Service Status:', cmd: 'systemctl status rmm-agent' },
|
|
{ label: 'Logs anzeigen:', cmd: 'journalctl -u rmm-agent -f' },
|
|
{ label: 'Agent neustarten:', cmd: 'systemctl restart rmm-agent' },
|
|
] : [
|
|
{ label: 'Service Status:', cmd: 'service rmm_agent status' },
|
|
{ label: 'Logs anzeigen:', cmd: 'tail -f /var/log/rmm-agent.log' },
|
|
{ label: 'Agent neustarten:', cmd: 'service rmm_agent restart' },
|
|
{ label: 'Updater Status:', cmd: 'service rmm_updater status' },
|
|
{ label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' },
|
|
]).map((item, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<span className="text-gray-500 text-xs w-40 shrink-0">{item.label}</span>
|
|
<code className="bg-gray-900 text-gray-300 text-xs px-2 py-1 rounded font-mono">{item.cmd}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent Info */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<h3 className="text-white font-semibold mb-3">Agent Info</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Plattform:</span>
|
|
<span className="text-white text-sm font-medium">{platform === 'linux' ? 'Linux' : platform === 'freebsd' ? 'FreeBSD / OPNsense' : platform}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Agent Version:</span>
|
|
<span className="text-white text-sm font-medium">{agent?.agent_version || '--'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Status:</span>
|
|
<span className={`text-sm font-medium ${
|
|
status === 'online' ? 'text-emerald-400' :
|
|
status === 'stale' ? 'text-yellow-400' : 'text-gray-500'
|
|
}`}>
|
|
{status === 'online' ? 'Online' : status === 'stale' ? 'Verzoegert' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Letzter Heartbeat:</span>
|
|
<span className="text-white text-sm">
|
|
{lastHB ? lastHB.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Agent ID:</span>
|
|
<span className="text-gray-400 text-xs font-mono">{agent?.id?.substring(0, 16) || '--'}...</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500 text-sm">Registriert:</span>
|
|
<span className="text-gray-400 text-sm">
|
|
{agent?.registered_at ? new Date(agent.registered_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HAProxyTab({ sys }) {
|
|
const ha = sys?.haproxy
|
|
if (!ha) return <div className="text-gray-500">Keine HAProxy-Daten</div>
|
|
const info = ha.info || {}
|
|
const entries = ha.entries || []
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-gray-300">HAProxy Info</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatusCard icon={Clock} value={info.uptime || '—'} label="Uptime" />
|
|
<StatusCard icon={Network} value={info.current_conns} label="Aktive Verb." />
|
|
<StatusCard icon={Shield} value={info.current_ssl} label="SSL Verb." />
|
|
<StatusCard value={info.total_requests?.toLocaleString('de-DE') || '0'} label="Requests Total" />
|
|
</div>
|
|
|
|
<h3 className="text-sm font-medium text-gray-300">Proxies</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Proxy</th>
|
|
<th className="px-3 py-2">Server</th>
|
|
<th className="px-3 py-2">Typ</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Sessions</th>
|
|
<th className="px-3 py-2">Requests</th>
|
|
<th className="px-3 py-2">Traffic In</th>
|
|
<th className="px-3 py-2">Traffic Out</th>
|
|
<th className="px-3 py-2">Fehler</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{entries.map((e, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white">{e.proxy_name}</td>
|
|
<td className="px-3 py-2 text-gray-400">{e.server_name}</td>
|
|
<td className="px-3 py-2"><span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-300">{e.type}</span></td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
['OPEN', 'UP'].includes(e.status) ? 'bg-green-900/50 text-green-400' :
|
|
e.status === 'DOWN' ? 'bg-red-900/50 text-red-400' :
|
|
'bg-gray-700 text-gray-400'
|
|
}`}>{e.status}</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">{e.current_sessions} / {e.total_sessions?.toLocaleString('de-DE')}</td>
|
|
<td className="px-3 py-2 text-gray-400">{e.requests_total?.toLocaleString('de-DE') || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{formatBytes(e.bytes_in)}</td>
|
|
<td className="px-3 py-2 text-gray-400">{formatBytes(e.bytes_out)}</td>
|
|
<td className="px-3 py-2 text-gray-400">{(e.errors_req || 0) + (e.errors_conn || 0) + (e.errors_resp || 0)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CaddyTab({ sys }) {
|
|
const caddy = sys?.caddy
|
|
if (!caddy) return <div className="text-gray-500">Keine Caddy-Daten</div>
|
|
|
|
const upstreams = caddy.upstreams || []
|
|
const healthyCount = upstreams.filter(u => u.healthy).length
|
|
const activeRequests = upstreams.reduce((s, u) => s + (u.num_requests || 0), 0)
|
|
const totalFails = upstreams.reduce((s, u) => s + (u.fails || 0), 0)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-gray-300">Caddy Reverse Proxy</h3>
|
|
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatusCard icon={Server} value={caddy.version || '—'} label="Version" />
|
|
<StatusCard icon={Network} value={`${healthyCount} / ${upstreams.length}`} label="Upstreams OK" />
|
|
<StatusCard icon={Globe} value={activeRequests} label="Aktive Requests" />
|
|
<StatusCard value={totalFails} label="Fehler gesamt" />
|
|
</div>
|
|
|
|
{upstreams.length > 0 && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Upstreams</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Adresse</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Aktive Req.</th>
|
|
<th className="px-3 py-2">Fehler</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{upstreams.map((u, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono">{u.address}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
u.healthy ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'
|
|
}`}>{u.healthy ? 'Healthy' : 'Unhealthy'}</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">{u.num_requests}</td>
|
|
<td className="px-3 py-2 text-gray-400">{u.fails}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<h3 className="text-sm font-medium text-gray-300">Konfigurierte Sites</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
|
<th className="px-3 py-2">Domain</th>
|
|
<th className="px-3 py-2">Upstream</th>
|
|
<th className="px-3 py-2">TLS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{(caddy.sites || []).map((s, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono">{s.domain}</td>
|
|
<td className="px-3 py-2 text-gray-400 font-mono">{s.upstream}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
s.tls ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'
|
|
}`}>{s.tls ? 'Aktiv' : 'Aus'}</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function gwColor(status) {
|
|
const s = (status || '').toLowerCase()
|
|
if (s === 'online' || s === 'none') return 'text-green-400'
|
|
if (s === 'offline' || s === 'down') return 'text-red-400'
|
|
return 'text-yellow-400'
|
|
}
|
|
|
|
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`
|
|
}
|