import { useEffect, useState } from 'react'
import api from '../api/client'
import { useSettingsStore } from '../stores/settings'
import StatusBadge from './StatusBadge'
import TerminalModal from './TerminalModal'
import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug,
} from 'lucide-react'
const buildTabs = (hasPBS) => [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'vms', label: 'Virtuelle Maschinen' },
{ id: 'containers', label: 'Container' },
{ id: 'storage', label: 'Storage' },
{ id: 'zfs', label: 'ZFS' },
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server' }] : []),
{ id: 'tunnel', label: 'Tunnel' },
{ id: 'services', label: 'Dienste' },
{ id: 'updates', label: 'Updates' },
{ id: 'backups', label: 'Backups' },
{ id: 'agent', label: 'Agent' },
]
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
const [data, setData] = useState(null)
const [tab, setTab] = useState('overview')
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
setTab('overview')
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
}, [agentId])
useEffect(() => {
if (!data) return
const iv = setInterval(() => {
api.getAgent(agentId).then(setData)
}, 30000)
return () => clearInterval(iv)
}, [agentId, data])
if (loading) return Laden...
if (!data) return Nicht gefunden
const { agent, system_data: sys, status } = data
const proxmox = sys?.proxmox
const pbs = sys?.pbs
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
const tabs = buildTabs(pbs?.available)
if (!proxmox?.available) {
return Keine Proxmox-Daten verfuegbar
}
return (
{/* Header */}
{agent.name}
{agent.hostname} · IP: {agent.ip} · PVE {proxmox.version || '—'}
{/* Tabs */}
{tabs.map((t) => (
))}
{/* Content */}
{!sys ? (
Keine Systemdaten
) : tab === 'overview' ? (
) : tab === 'vms' ? (
) : tab === 'containers' ? (
) : tab === 'storage' ? (
) : tab === 'zfs' ? (
) : tab === 'tunnel' ? (
) : tab === 'services' ? (
) : tab === 'updates' ? (
) : tab === 'pbs' ? (
) : tab === 'backups' ? (
) : tab === 'agent' ? (
) : null}
)
}
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)
}
}
return (
{!editing ? (
) : (
)}
)
}
function Panel({ onClose, children }) {
return (
<>
{children}
>
)
}
function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
return (
{Icon && }
{label}
{value}
{sub &&
{sub}
}
{bar !== undefined && bar !== null && (
)}
)
}
function StatusCard({ icon: Icon, value, label }) {
return (
{Icon && }
{value}
{label}
)
}
function OverviewTab({ sys, proxmox }) {
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 vmsRunning = proxmox.vms?.filter(vm => vm.status === 'running').length || 0
const vmsTotal = proxmox.vms?.length || 0
const ctRunning = proxmox.containers?.filter(ct => ct.status === 'running').length || 0
const ctTotal = proxmox.containers?.length || 0
const storageCount = proxmox.storage?.length || 0
const zfsCount = proxmox.zfs_pools?.length || 0
return (
<>
System-Metriken
Proxmox-Info
PVE Version
{proxmox.version || '—'}
Node Name
{proxmox.node || '—'}
Cluster Name
{proxmox.cluster?.name || '—'}
{/* Subscription */}
{proxmox.subscription && (
<>
Subscription
{proxmox.subscription.productname || 'Proxmox VE'}
{proxmox.subscription.key && (
{proxmox.subscription.key}
)}
{proxmox.subscription.status || 'Unknown'}
{proxmox.subscription.next_due_date && (
bis {proxmox.subscription.next_due_date}
)}
>
)}
Kurzuebersicht
>
)
}
function VmsTab({ proxmox }) {
const vms = proxmox.vms || []
// Sort: running first, then by name
const sortedVms = [...vms].sort((a, b) => {
if (a.status === 'running' && b.status !== 'running') return -1
if (a.status !== 'running' && b.status === 'running') return 1
return a.name.localeCompare(b.name)
})
return (
<>
Virtuelle Maschinen ({vms.length})
{sortedVms.length === 0 ? (
Keine VMs gefunden
) : (
| VMID |
Name |
Status |
CPU |
RAM |
Disk |
Uptime |
{sortedVms.map((vm) => (
| {vm.vmid} |
{vm.name} |
|
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
{vm.memory_used && vm.memory_max ?
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
{vm.disk_used != null && vm.disk_max ?
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
))}
)}
>
)
}
function ContainersTab({ proxmox }) {
const containers = proxmox.containers || []
// Sort: running first, then by name
const sortedCt = [...containers].sort((a, b) => {
if (a.status === 'running' && b.status !== 'running') return -1
if (a.status !== 'running' && b.status === 'running') return 1
return a.name.localeCompare(b.name)
})
return (
<>
Container ({containers.length})
{sortedCt.length === 0 ? (
Keine Container gefunden
) : (
| VMID |
Name |
Status |
CPU |
RAM |
Uptime |
{sortedCt.map((ct) => (
| {ct.vmid} |
{ct.name} |
|
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
{ct.memory_used && ct.memory_max ?
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
))}
)}
>
)
}
function StorageTab({ proxmox }) {
const storage = proxmox.storage || []
return (
<>
Storage ({storage.length})
{storage.length === 0 ? (
Keine Storage-Pools gefunden
) : (
{storage.map((s) => {
const usedPct = s.total && s.total > 0 ? (s.used / s.total * 100) : 0
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
return (
{s.storage}
{s.type} • {s.content}
{s.total && (
<>
{formatBytes(s.used)} / {formatBytes(s.total)}
{usedPct.toFixed(1)}%
>
)}
)
})}
)}
>
)
}
function ZfsTab({ proxmox }) {
const zfsPools = proxmox.zfs_pools || []
return (
<>
ZFS Pools ({zfsPools.length})
{zfsPools.length === 0 ? (
Keine ZFS-Pools gefunden
) : (
{zfsPools.map((pool) => {
const healthColor = pool.health === 'ONLINE' ? 'text-green-400' :
pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400'
const healthBg = pool.health === 'ONLINE' ? 'bg-green-500' :
pool.health === 'DEGRADED' ? 'bg-yellow-500' : 'bg-red-500'
return (
{pool.name}
{pool.health}
{(() => {
const usedPct = pool.size > 0 ? (pool.allocated / pool.size * 100) : 0
const pctColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
return (
<>
Groesse:
{formatBytes(pool.size)}
Belegt:
{formatBytes(pool.allocated)} ({usedPct.toFixed(1)}%)
Frei:
{formatBytes(pool.free)}
{pool.fragmentation && (
Fragmentierung:
{pool.fragmentation}
)}
{pool.scrub_status && (
)}
>
)
})()}
)
})}
)}
>
)
}
function ServicesTab({ sys }) {
const services = sys.services || []
// Sort: running first
const sortedServices = [...services].sort((a, b) => {
const aRunning = a.running || a.status === 'running'
const bRunning = b.running || b.status === 'running'
if (aRunning && !bRunning) return -1
if (!aRunning && bRunning) return 1
return a.name.localeCompare(b.name)
})
return (
<>
Systemd Dienste ({services.length})
{sortedServices.length === 0 ? (
Keine Dienste gefunden
) : (
| Name |
Status |
{sortedServices.map((svc, i) => {
const running = svc.running || svc.status === 'running'
return (
| {svc.name} |
|
)
})}
)}
>
)
}
function UpdatesTab({ sys }) {
const pendingUpdates = sys.pending_updates || []
return (
<>
Updates
{pendingUpdates.length} Updates verfuegbar
{pendingUpdates.length === 0 ? (
Keine Updates verfuegbar
) : (
| Package |
Current Version |
New Version |
{pendingUpdates.map((update, i) => (
| {update.package} |
{update.current_version} |
{update.new_version} |
))}
)}
>
)
}
function AgentTab({ agent, status }) {
return (
<>
Agent Information
Agent Version
{agent?.agent_version || '—'}
Last Heartbeat
{agent?.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—'}
Agent ID
{agent?.id?.substring(0, 12)}...
>
)
}
function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
const [tunnels, setTunnels] = useState([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(null)
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 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
loadTunnels()
if (data?.proxy_port) {
setTimeout(() => {
if (targetPort === webguiPort) {
window.open(`https://${backendHost}:${data.proxy_port}`, '_blank')
} else if (targetPort === 22) {
const cmd = `ssh root@${backendHost} -p ${data.proxy_port}`
navigator.clipboard.writeText(cmd).catch(() => {})
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 (
{terminalOpen && (
setTerminalOpen(false)} />
)}
{/* Schnellzugriff */}
Schnellzugriff:
{/* Custom Form */}
{customOpen && (
)}
{/* SSH auto-kopiert Bestätigung */}
{sshCopied && (
SSH-Befehl kopiert — in CMD/Terminal einfuegen und ausfuehren
)}
{error && {error}
}
{/* Aktive Tunnel */}
Aktive Tunnel
{loading ? (
Laden...
) : tunnels.length === 0 ? (
Keine aktiven Tunnel
) : (
| Ziel |
Lokaler Zugang |
Status |
Erstellt |
Aktionen |
{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}` : '—'
const isWebGUI = t.target_port === webguiPort || t.target_port === String(webguiPort)
const isSSH = t.target_port === 22 || t.target_port === '22'
return (
| {target} |
{backendHost}:{proxyPort}
{isWebGUI && proxyPort && (
)}
{isSSH && proxyPort && (
)}
|
aktiv
|
{t.created_at ? new Date(t.created_at).toLocaleString('de-DE') : '—'}
|
|
)
})}
)}
Tunnel werden ueber den RMM-Backend-Server ({backendHost}) geroutet. WebGUI-Tunnel oeffnen automatisch ein neues Browserfenster.
)
}
function BackupsTab({ sys }) {
const backups = sys?.backups || {}
const jobs = backups.jobs || []
const tasks = backups.tasks || []
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
return (
{/* Stats */}
Aufgaben (30d)
{tasks.length}
Erfolgsrate
= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
{successRate !== null ? `${successRate}%` : '--'}
{/* Backup Jobs */}
Backup Jobs
{jobs.length === 0 ? (
Keine Backup-Jobs konfiguriert
) : (
| ID |
Storage |
Schedule |
Modus |
VMs/CTs |
Status |
{jobs.map((j, i) => (
| {j.id || i} |
{j.storage || '—'} |
{j.schedule || '—'} |
{j.mode || '—'} |
{j.vmid || j.all ? 'Alle' : '—'} |
{j.enabled ? 'Aktiv' : 'Inaktiv'}
|
))}
)}
{/* Task History */}
Letzte Backup-Aufgaben
{tasks.length === 0 ? (
Keine Backup-Aufgaben in den letzten 30 Tagen
) : (
| Zeitpunkt |
Typ |
VMID |
Status |
{tasks.slice(0, 50).map((t, i) => (
|
{t.starttime ? new Date(t.starttime * 1000).toLocaleString('de-DE') : '—'}
|
{t.type || 'vzdump'} |
{t.id || '—'} |
{t.status || '—'}
|
))}
)}
{failedTasks > 0 && (
{failedTasks} fehlgeschlagene Backup(s) in den letzten 30 Tagen
)}
)
}
function formatBytes(bytes) {
if (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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + 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`
return `${h}h ${m}m`
}
function PBSTab({ pbs, agentId, sys }) {
const [loading, setLoading] = useState({})
const [feedback, setFeedback] = useState({})
if (!pbs?.available) return Keine PBS-Daten verfuegbar
const formatBytes = (b) => {
if (!b) return '—'
if (b >= 1099511627776) return `${(b / 1099511627776).toFixed(1)} TB`
if (b >= 1073741824) return `${(b / 1073741824).toFixed(1)} GB`
if (b >= 1048576) return `${(b / 1048576).toFixed(1)} MB`
return `${b} B`
}
const formatTs = (ts) => ts ? new Date(ts * 1000).toLocaleString('de-DE') : '—'
const formatDuration = (s) => {
if (!s) return '—'
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`
}
const taskStatusColor = (s) => {
if (!s) return 'text-gray-500'
if (s === 'OK' || s.startsWith('OK')) return 'text-green-400'
if (s.toLowerCase().includes('err') || s.toLowerCase().includes('fail')) return 'text-red-400'
if (s === 'running') return 'text-blue-400'
return 'text-yellow-400'
}
const pbsAction = async (datastore, action) => {
const key = `${datastore}-${action}`
const cmds = {
gc: `/usr/sbin/proxmox-backup-manager garbage-collection start ${datastore}`,
verify: `/usr/sbin/proxmox-backup-manager verify ${datastore}`,
prune: `/usr/sbin/proxmox-backup-manager prune-datastore ${datastore}`,
}
setLoading(l => ({ ...l, [key]: true }))
setFeedback(f => ({ ...f, [key]: null }))
try {
await api.execCommand(agentId, cmds[action], 60)
setFeedback(f => ({ ...f, [key]: 'ok' }))
} catch (e) {
setFeedback(f => ({ ...f, [key]: 'err' }))
} finally {
setLoading(l => ({ ...l, [key]: false }))
setTimeout(() => setFeedback(f => ({ ...f, [key]: null })), 4000)
}
}
const totalBackups = pbs.datastores?.reduce((s, d) => s + (d.backup_count || 0), 0) || 0
const runningTasks = pbs.tasks?.filter(t => t.running)?.length || 0
const StatCard = ({ icon, value, label, iconColor, highlight }) => (
)
return (
{/* Titel */}
Backup Server
{pbs.version &&
PBS {pbs.version}
}
{/* Stat-Karten */}
}
value={pbs.datastores?.length || 0}
label="Datastores"
iconColor="text-orange-400"
/>
}
value={totalBackups}
label="Backups"
iconColor="text-blue-400"
/>
}
value={pbs.sync_jobs ?? 0}
label="Sync Jobs"
iconColor="text-green-400"
/>
}
value={runningTasks > 0 ? runningTasks : '—'}
label="Laufend"
iconColor={runningTasks > 0 ? "text-blue-400" : "text-gray-600"}
highlight={runningTasks > 0}
/>
{/* Datastores */}
{pbs.datastores?.length > 0 && (
Datastores
{pbs.datastores.map((ds) => {
const usedPct = ds.total > 0 ? (ds.used / ds.total * 100) : 0
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
const gc = pbs.gc?.find(g => g.datastore === ds.name)
const mkKey = (a) => `${ds.name}-${a}`
const ActionBtn = ({ action, label, cls }) => {
const k = mkKey(action)
const fb = feedback[k]
return (
)
}
return (
{ds.backup_count > 0 &&
{ds.backup_count} Backups}
{formatBytes(ds.used)} / {formatBytes(ds.total)}
{usedPct.toFixed(1)}%
{gc?.status && (
GC: {gc.status}
{gc.last_run > 0 && Letzter Lauf: {formatTs(gc.last_run)}}
{gc.duration > 0 && ({formatDuration(gc.duration)})}
)}
)
})}
)}
{/* Tasks */}
{pbs.tasks?.length > 0 && (
Letzte Tasks
| Typ |
Start |
Dauer |
Status |
{pbs.tasks.map((t, i) => (
|
{t.type || '—'}
{t.running && RUNNING}
|
{formatTs(t.start_time)} |
{t.running ? läuft... : formatDuration(t.duration)} |
{t.status || '—'} |
))}
)}
)
}