1766 lines
81 KiB
JavaScript
1766 lines
81 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import api from '../api/client'
|
|
import { useSettingsStore } from '../stores/settings'
|
|
import StatusBadge from './StatusBadge'
|
|
import TerminalModal from './TerminalModal'
|
|
import ScriptModal from './ScriptModal'
|
|
import { copyToClipboard } from '../utils/clipboard'
|
|
import {
|
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
|
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
|
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode,
|
|
LayoutGrid, Archive, GitBranch, Wrench, RefreshCw, CalendarClock, Bot,
|
|
} from 'lucide-react'
|
|
|
|
const mainCategories = [
|
|
{ id: 'uebersicht', label: 'Uebersicht' },
|
|
{ id: 'virtualisierung', label: 'Virtualisierung' },
|
|
{ id: 'speicher', label: 'Speicher' },
|
|
{ id: 'system', label: 'System' },
|
|
]
|
|
|
|
const buildSubTabs = (hasPBS) => ({
|
|
virtualisierung: [
|
|
{ id: 'vms', label: 'VMs', icon: Monitor },
|
|
{ id: 'containers', label: 'Container', icon: Container },
|
|
],
|
|
speicher: [
|
|
{ id: 'storage', label: 'Storage', icon: HardDrive },
|
|
{ id: 'zfs', label: 'ZFS', icon: Database },
|
|
{ id: 'backups', label: 'Backups', icon: Archive },
|
|
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server', icon: Server }] : []),
|
|
],
|
|
system: [
|
|
{ id: 'services', label: 'Dienste', icon: Settings },
|
|
{ id: 'updates', label: 'Updates', icon: RefreshCw },
|
|
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
|
|
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
|
|
{ id: 'agent', label: 'Agent', icon: Bot },
|
|
],
|
|
})
|
|
|
|
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
|
|
const [data, setData] = useState(null)
|
|
const [mainTab, setMainTab] = useState('uebersicht')
|
|
const [subTab, setSubTab] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setMainTab('uebersicht')
|
|
setSubTab(null)
|
|
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
|
}, [agentId])
|
|
|
|
const handleMainTab = (id, subTabs) => {
|
|
setMainTab(id)
|
|
setSubTab(subTabs?.[id]?.[0]?.id ?? null)
|
|
}
|
|
|
|
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 proxmox = sys?.proxmox
|
|
const pbs = sys?.pbs
|
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
|
const subs = buildSubTabs(pbs?.available)
|
|
const currentSubs = mainTab !== 'uebersicht' ? (subs[mainTab] || []) : []
|
|
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
|
|
|
|
if (!proxmox?.available) {
|
|
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
|
|
}
|
|
|
|
const renderContent = () => {
|
|
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
|
|
if (mainTab === 'uebersicht') return <OverviewTab sys={sys} proxmox={proxmox} />
|
|
const t = activeSubTab
|
|
if (t === 'vms') return <VmsTab proxmox={proxmox} agentId={agentId} />
|
|
if (t === 'containers') return <ContainersTab proxmox={proxmox} agentId={agentId} />
|
|
if (t === 'storage') return <StorageTab proxmox={proxmox} />
|
|
if (t === 'zfs') return <ZfsTab proxmox={proxmox} />
|
|
if (t === 'backups') return <BackupsTab sys={sys} />
|
|
if (t === 'pbs') return <PBSTab pbs={pbs} agentId={agentId} sys={sys} />
|
|
if (t === 'services') return <ServicesTab sys={sys} />
|
|
if (t === 'updates') return <UpdatesTab sys={sys} />
|
|
if (t === 'tasks') return <LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
|
|
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
|
|
if (t === 'agent') return <AgentTab agent={data?.agent} status={data?.status} />
|
|
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} · PVE {proxmox.version || '—'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
if (!confirm(`Server "${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="Server 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>
|
|
|
|
{/* Haupt-Tabs */}
|
|
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
|
{mainCategories.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => handleMainTab(cat.id, subs)}
|
|
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 + optionale Sidebar */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
|
|
{/* Sub-Navigation links — nur wenn nicht 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 = activeSubTab === 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>
|
|
)}
|
|
|
|
{/* Hauptinhalt */}
|
|
<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)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
{!editing ? (
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="hover:text-orange-400 transition-colors"
|
|
>
|
|
Kunde: {current ? `${current.number} — ${current.name}` : 'Kein Kunde'}
|
|
</button>
|
|
) : (
|
|
<select
|
|
onChange={handleChange}
|
|
defaultValue={current?.id || ''}
|
|
disabled={saving}
|
|
className="bg-gray-800 border border-gray-600 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
|
onBlur={() => setEditing(false)}
|
|
autoFocus
|
|
>
|
|
<option value="">Kein Kunde</option>
|
|
{customers.map(c => (
|
|
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</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-5xl 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 MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<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>
|
|
)
|
|
}
|
|
|
|
function StatusCard({ icon: Icon, value, label }) {
|
|
return (
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
|
<div className="flex justify-center mb-2">
|
|
{Icon && <Icon className="w-5 h-5 text-gray-500" />}
|
|
</div>
|
|
<div className="text-lg font-bold text-white">{value}</div>
|
|
<div className="text-xs text-gray-500 uppercase">{label}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LinuxTasksTab({ agentId, agentName }) {
|
|
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 defaultForm = {
|
|
name: '', action: 'update', schedule_type: 'monthly', schedule_time: '02:00',
|
|
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
|
|
reboot: true, health_check: true, health_check_timeout: 600,
|
|
security_only: false, webhook_url: '', active: true,
|
|
script: '', script_timeout: 300,
|
|
}
|
|
const [form, setForm] = useState(defaultForm)
|
|
|
|
const templates = [
|
|
{ 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, webhook_url: '', active: true } },
|
|
{ label: 'ZFS Scrub (monatlich)', form: { name: 'Monatlicher ZFS Pool Scrub', action: 'zpoolscrub', schedule_type: 'monthly', schedule_time: '01:37', schedule_monthday: 20, reboot: false, health_check: false, active: true } },
|
|
{ label: 'ZFS Scrub (wöchentlich)', form: { name: 'Wöchentlicher ZFS Pool Scrub', action: 'zpoolscrub', schedule_type: 'weekly', schedule_time: '01:00', schedule_weekday: 0, reboot: false, health_check: false, active: true } },
|
|
{ label: 'Custom Script', form: { name: 'Benutzerdefiniertes Script', action: 'script', schedule_type: 'monthly', schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, script: '#!/bin/bash\n\n', script_timeout: 300, active: true } },
|
|
]
|
|
|
|
const loadTasks = () => {
|
|
api.getTasks({ agent_id: agentId, limit: 50 })
|
|
.then(r => { setTasks(r.data || []); setTotal(r.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,
|
|
reboot: form.reboot, health_check: form.health_check,
|
|
health_check_timeout: parseInt(form.health_check_timeout) || 600,
|
|
security_only: form.security_only || false,
|
|
script: form.script || '',
|
|
script_timeout: parseInt(form.script_timeout) || 300,
|
|
}
|
|
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.webhook_url) data.webhook_url = form.webhook_url
|
|
await api.createTask(data)
|
|
setShowDialog(false); setForm(defaultForm); loadTasks()
|
|
} catch (e) { setError(e.message) } finally { setCreating(false) }
|
|
}
|
|
|
|
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,
|
|
security_only: form.security_only || false, webhook_url: form.webhook_url,
|
|
script: form.script || '', script_timeout: parseInt(form.script_timeout) || 300,
|
|
}
|
|
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); loadTasks()
|
|
} catch (e) { setError(e.message) } finally { setCreating(false) }
|
|
}
|
|
|
|
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'
|
|
|
|
const statusColor = (s) => {
|
|
if (s === 'completed') return 'text-emerald-400'
|
|
if (s === 'failed' || s === 'error') return 'text-red-400'
|
|
if (s === 'running') return 'text-blue-400'
|
|
if (s === 'completed_with_warning') return 'text-yellow-400'
|
|
return 'text-gray-400'
|
|
}
|
|
|
|
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); setEditTask(null); setForm(defaultForm) }}
|
|
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` : `Neuer Job — ${agentName}`}</h3>
|
|
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
|
|
</div>
|
|
|
|
{/* Vorlagen */}
|
|
{!editTask && (
|
|
<div className="px-5 pt-4 pb-3 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">
|
|
{templates.map((tpl, i) => (
|
|
<button key={i} onClick={() => setForm({ ...defaultForm, ...tpl.form })}
|
|
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 === '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>
|
|
)}
|
|
|
|
{/* Aktion wählen */}
|
|
<div>
|
|
<label className={labelCls}>Aktion *</label>
|
|
<select value={form.action} onChange={e => setForm({...form, action: e.target.value})} className={inputCls} disabled={!!editTask}>
|
|
<option value="update">Update (apt-get)</option>
|
|
<option value="zpoolscrub">ZFS Pool Scrub</option>
|
|
<option value="script">Custom Script</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Script-Eingabe (nur bei action=script) */}
|
|
{form.action === 'script' && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<label className={labelCls}>Script-Inhalt *</label>
|
|
<textarea
|
|
value={form.script}
|
|
onChange={e => setForm({...form, script: e.target.value})}
|
|
rows={8}
|
|
spellCheck={false}
|
|
placeholder={'#!/bin/bash\n\n# Script hier eingeben...'}
|
|
className="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-xs font-mono text-gray-200 focus:outline-none focus:border-orange-500 resize-y"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Timeout (Sekunden)</label>
|
|
<input type="number" value={form.script_timeout} onChange={e => setForm({...form, script_timeout: e.target.value})} className={inputCls + ' max-w-[160px]'} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Update-Optionen (nur bei action=update) */}
|
|
{form.action === 'update' && (
|
|
<div className="space-y-2 pl-1">
|
|
<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" />
|
|
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5 text-red-400" /> Nur Security-Updates</span>
|
|
</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}>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-[160px]'} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className={labelCls}>Webhook-URL (optional)</label>
|
|
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls} />
|
|
</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) }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300">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">
|
|
{creating ? 'Speichere...' : editTask ? 'Speichern' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabelle */}
|
|
{loading ? <div className="text-gray-500 text-sm">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">Name</th>
|
|
<th className="px-3 py-2">Zeitplan</th>
|
|
<th className="px-3 py-2">Optionen</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">Letzter Lauf</th>
|
|
<th className="px-3 py-2">Nächster Lauf</th>
|
|
<th className="px-3 py-2"></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 text-gray-200 font-medium">{t.name}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{t.security_only && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 border border-red-700/50 text-red-300 flex items-center gap-0.5"><Shield className="w-2.5 h-2.5" />SEC</span>}
|
|
{t.reboot && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Reboot</span>}
|
|
{t.health_check && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Health</span>}
|
|
</div>
|
|
</td>
|
|
<td className={`px-3 py-2 text-xs font-medium ${statusColor(t.status)}`}>{t.status || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-500 text-xs">{fmtDate(t.last_run)}</td>
|
|
<td className="px-3 py-2 text-gray-500 text-xs">{fmtDate(t.next_run)}</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex gap-1">
|
|
<button onClick={() => api.runTaskNow(t.id).then(() => setTimeout(loadTasks, 1500))} title="Jetzt ausführen" className="p-1 text-gray-500 hover:text-orange-400"><Download className="w-3.5 h-3.5" /></button>
|
|
<button onClick={() => { 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, security_only: t.security_only || false, script: t.script || '', script_timeout: t.script_timeout || 300, webhook_url: t.webhook_url || '', active: t.active !== false }); setShowDialog(true) }} title="Bearbeiten" className="p-1 text-gray-500 hover:text-blue-400"><Settings className="w-3.5 h-3.5" /></button>
|
|
<button onClick={() => api.toggleTask(t.id).then(loadTasks)} title={t.active ? 'Deaktivieren' : 'Aktivieren'} className="p-1 text-gray-500 hover:text-yellow-400"><Monitor className="w-3.5 h-3.5" /></button>
|
|
<button onClick={() => confirm('Job löschen?') && api.deleteTask(t.id).then(loadTasks)} title="Löschen" className="p-1 text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">System-Metriken</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<MetricCard icon={Cpu} label="CPU Auslastung" value={cpuPct != null ? `${cpuPct.toFixed(1)}%` : '—'}
|
|
sub={sys.cpu?.model} bar={cpuPct} barColor={barColor(cpuPct || 0)} />
|
|
<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(ramPct || 0)} />
|
|
<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) : '—'} />
|
|
</div>
|
|
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Proxmox-Info</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">PVE Version</div>
|
|
<div className="text-white font-medium">{proxmox.version || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Node Name</div>
|
|
<div className="text-white font-medium">{proxmox.node || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Cluster Name</div>
|
|
<div className="text-white font-medium">{proxmox.cluster?.name || '—'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subscription */}
|
|
{proxmox.subscription && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Subscription</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">{proxmox.subscription.productname || 'Proxmox VE'}</div>
|
|
{proxmox.subscription.key && (
|
|
<div className="text-xs text-gray-500 mt-1 font-mono">{proxmox.subscription.key}</div>
|
|
)}
|
|
</div>
|
|
<div className="text-right">
|
|
<div className={`text-sm font-bold ${
|
|
proxmox.subscription.status === 'Active' ? 'text-green-400' : 'text-red-400'
|
|
}`}>
|
|
{proxmox.subscription.status || 'Unknown'}
|
|
</div>
|
|
{proxmox.subscription.next_due_date && (
|
|
<div className="text-xs text-gray-500">bis {proxmox.subscription.next_due_date}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Kurzuebersicht</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatusCard icon={Monitor} value={`${vmsRunning}/${vmsTotal}`} label="VMs" />
|
|
<StatusCard icon={Container} value={`${ctRunning}/${ctTotal}`} label="Container" />
|
|
<StatusCard icon={Database} value={storageCount} label="Storage" />
|
|
<StatusCard icon={FolderTree} value={zfsCount} label="ZFS Pools" />
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function VmActionButtons({ status, loading, onStart, onStop, onShutdown }) {
|
|
const isRunning = status === 'running'
|
|
const isStopped = status === 'stopped'
|
|
|
|
if (loading) {
|
|
return <span className="text-xs text-gray-500 animate-pulse">...</span>
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{isStopped && (
|
|
<button
|
|
onClick={onStart}
|
|
title="Starten"
|
|
className="p-1 rounded text-green-400 hover:bg-green-900/40 hover:text-green-300 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.344-5.891a1.5 1.5 0 000-2.538L6.3 2.84z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{isRunning && onShutdown && (
|
|
<button
|
|
onClick={onShutdown}
|
|
title="Graceful Shutdown"
|
|
className="p-1 rounded text-yellow-400 hover:bg-yellow-900/40 hover:text-yellow-300 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{isRunning && (
|
|
<button
|
|
onClick={onStop}
|
|
title="Hard Stop"
|
|
className="p-1 rounded text-red-400 hover:bg-red-900/40 hover:text-red-300 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M4 4h12v12H4V4z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VmsTab({ proxmox, agentId }) {
|
|
const vms = proxmox.vms || []
|
|
const [pending, setPending] = useState({}) // vmid -> true wenn Action läuft
|
|
const [errors, setErrors] = useState({})
|
|
|
|
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)
|
|
})
|
|
|
|
const doAction = async (vmid, action) => {
|
|
setPending(p => ({ ...p, [vmid]: true }))
|
|
setErrors(e => ({ ...e, [vmid]: null }))
|
|
try {
|
|
await api.proxmoxAction(agentId, 'vm', vmid, action)
|
|
} catch (err) {
|
|
setErrors(e => ({ ...e, [vmid]: err.message }))
|
|
} finally {
|
|
setPending(p => ({ ...p, [vmid]: false }))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
|
{sortedVms.length === 0 ? (
|
|
<div className="text-gray-500">Keine VMs gefunden</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">VMID</th>
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">CPU</th>
|
|
<th className="px-3 py-2">RAM</th>
|
|
<th className="px-3 py-2">Disk</th>
|
|
<th className="px-3 py-2">Uptime</th>
|
|
<th className="px-3 py-2"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sortedVms.map((vm) => (
|
|
<tr key={vm.vmid}>
|
|
<td className="px-3 py-2 text-gray-400">{vm.vmid}</td>
|
|
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
|
<td className="px-3 py-2">
|
|
<StatusBadge status={vm.status} />
|
|
{errors[vm.vmid] && <div className="text-red-400 text-xs mt-1">{errors[vm.vmid]}</div>}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{vm.memory_used && vm.memory_max ?
|
|
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{vm.disk_used != null && vm.disk_max ?
|
|
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<VmActionButtons
|
|
status={vm.status}
|
|
loading={!!pending[vm.vmid]}
|
|
onStart={() => doAction(vm.vmid, 'start')}
|
|
onStop={() => doAction(vm.vmid, 'stop')}
|
|
onShutdown={() => doAction(vm.vmid, 'shutdown')}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ContainersTab({ proxmox, agentId }) {
|
|
const containers = proxmox.containers || []
|
|
const [pending, setPending] = useState({})
|
|
const [errors, setErrors] = useState({})
|
|
|
|
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)
|
|
})
|
|
|
|
const doAction = async (vmid, action) => {
|
|
setPending(p => ({ ...p, [vmid]: true }))
|
|
setErrors(e => ({ ...e, [vmid]: null }))
|
|
try {
|
|
await api.proxmoxAction(agentId, 'ct', vmid, action)
|
|
} catch (err) {
|
|
setErrors(e => ({ ...e, [vmid]: err.message }))
|
|
} finally {
|
|
setPending(p => ({ ...p, [vmid]: false }))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
|
{sortedCt.length === 0 ? (
|
|
<div className="text-gray-500">Keine Container gefunden</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">VMID</th>
|
|
<th className="px-3 py-2">Name</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
<th className="px-3 py-2">CPU</th>
|
|
<th className="px-3 py-2">RAM</th>
|
|
<th className="px-3 py-2">Uptime</th>
|
|
<th className="px-3 py-2"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sortedCt.map((ct) => (
|
|
<tr key={ct.vmid}>
|
|
<td className="px-3 py-2 text-gray-400">{ct.vmid}</td>
|
|
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
|
<td className="px-3 py-2">
|
|
<StatusBadge status={ct.status} />
|
|
{errors[ct.vmid] && <div className="text-red-400 text-xs mt-1">{errors[ct.vmid]}</div>}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{ct.memory_used && ct.memory_max ?
|
|
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400">
|
|
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<VmActionButtons
|
|
status={ct.status}
|
|
loading={!!pending[ct.vmid]}
|
|
onStart={() => doAction(ct.vmid, 'start')}
|
|
onStop={() => doAction(ct.vmid, 'stop')}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function StorageTab({ proxmox }) {
|
|
const storage = proxmox.storage || []
|
|
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Storage ({storage.length})</h3>
|
|
{storage.length === 0 ? (
|
|
<div className="text-gray-500">Keine Storage-Pools gefunden</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{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 (
|
|
<div key={s.storage} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<div className="text-white font-medium">{s.storage}</div>
|
|
<div className="text-xs text-gray-500">{s.type} • {s.content}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<StatusBadge status={s.active ? 'online' : 'offline'} />
|
|
</div>
|
|
</div>
|
|
|
|
{s.total && (
|
|
<>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-gray-400">{formatBytes(s.used)} / {formatBytes(s.total)}</span>
|
|
<span className="text-gray-400">{usedPct.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden">
|
|
<div className={`h-full rounded ${barColor}`} style={{ width: `${usedPct}%` }} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ZfsTab({ proxmox }) {
|
|
const zfsPools = proxmox.zfs_pools || []
|
|
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">ZFS Pools ({zfsPools.length})</h3>
|
|
{zfsPools.length === 0 ? (
|
|
<div className="text-gray-500">Keine ZFS-Pools gefunden</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{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 (
|
|
<div key={pool.name} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="text-white font-medium">{pool.name}</div>
|
|
<div className={`text-xs font-bold ${healthColor}`}>{pool.health}</div>
|
|
</div>
|
|
|
|
{(() => {
|
|
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 (
|
|
<>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Groesse:</span>
|
|
<span className="text-gray-300">{formatBytes(pool.size)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Belegt:</span>
|
|
<span className="text-gray-300">{formatBytes(pool.allocated)} ({usedPct.toFixed(1)}%)</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Frei:</span>
|
|
<span className="text-gray-300">{formatBytes(pool.free)}</span>
|
|
</div>
|
|
{pool.fragmentation && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Fragmentierung:</span>
|
|
<span className="text-gray-300">{pool.fragmentation}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
|
<div className={`h-full rounded ${pctColor}`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
|
</div>
|
|
|
|
{pool.scrub_status && (
|
|
<div className="mt-3 pt-3 border-t border-gray-700">
|
|
<div className="text-xs text-gray-500">{pool.scrub_status}</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Systemd Dienste ({services.length})</h3>
|
|
{sortedServices.length === 0 ? (
|
|
<div className="text-gray-500">Keine Dienste gefunden</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">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{sortedServices.map((svc, i) => {
|
|
const running = svc.running || svc.status === 'running'
|
|
return (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white">{svc.name}</td>
|
|
<td className="px-3 py-2">
|
|
<StatusBadge status={running ? 'running' : 'stopped'} />
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function UpdatesTab({ sys }) {
|
|
const [filter, setFilter] = useState('all')
|
|
const [search, setSearch] = useState('')
|
|
|
|
const updates = sys?.updates?.updates || sys?.pending_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
|
|
})
|
|
|
|
if (updates.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
|
<div className="w-10 h-10 rounded-full bg-emerald-900/40 flex items-center justify-center 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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
|
|
{/* 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-36 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentTab({ agent, status }) {
|
|
return (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300">Agent Information</h3>
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Agent Version</div>
|
|
<div className="text-white">{agent?.agent_version || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Status</div>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Last Heartbeat</div>
|
|
<div className="text-white text-xs">
|
|
{agent?.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-gray-500 uppercase">Agent ID</div>
|
|
<div className="text-white text-xs font-mono">{agent?.id?.substring(0, 12)}...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 [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
|
|
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}`
|
|
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)} />
|
|
)}
|
|
|
|
{/* Schnellzugriff */}
|
|
<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', webguiPort, '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 (${webguiPort})`}
|
|
</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 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>
|
|
)}
|
|
|
|
{/* SSH auto-kopiert Bestätigung */}
|
|
{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>}
|
|
|
|
{/* Aktive Tunnel */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Aktive Tunnel</h3>
|
|
{loading ? (
|
|
<div className="text-gray-500 text-sm">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}` : '—'
|
|
const isWebGUI = t.target_port === webguiPort || t.target_port === String(webguiPort)
|
|
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">{backendHost}:{proxyPort}</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'
|
|
}`}
|
|
>
|
|
<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>
|
|
|
|
<div className="text-xs text-gray-600">
|
|
Tunnel werden ueber den RMM-Backend-Server ({backendHost}) geroutet. WebGUI-Tunnel oeffnen automatisch ein neues Browserfenster.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<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 (aktiv)</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">Aufgaben (30d)</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">Erfolgreich</div>
|
|
<div className="text-xl font-bold text-emerald-400">{okTasks}</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>
|
|
|
|
{/* Backup Jobs */}
|
|
<h3 className="text-sm font-medium text-gray-300">Backup Jobs</h3>
|
|
{jobs.length === 0 ? (
|
|
<div className="text-gray-500">Keine Backup-Jobs 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">ID</th>
|
|
<th className="px-3 py-2">Storage</th>
|
|
<th className="px-3 py-2">Schedule</th>
|
|
<th className="px-3 py-2">Modus</th>
|
|
<th className="px-3 py-2">VMs/CTs</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{jobs.map((j, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-white font-mono text-xs">{j.id || i}</td>
|
|
<td className="px-3 py-2 text-gray-400">{j.storage || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{j.schedule || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{j.mode || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{j.vmid || j.all ? 'Alle' : '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs ${j.enabled ? 'text-green-400' : 'text-gray-500'}`}>
|
|
{j.enabled ? 'Aktiv' : 'Inaktiv'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Task History */}
|
|
<h3 className="text-sm font-medium text-gray-300">Letzte Backup-Aufgaben</h3>
|
|
{tasks.length === 0 ? (
|
|
<div className="text-gray-500">Keine Backup-Aufgaben in den letzten 30 Tagen</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">Zeitpunkt</th>
|
|
<th className="px-3 py-2">Typ</th>
|
|
<th className="px-3 py-2">VMID</th>
|
|
<th className="px-3 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{tasks.slice(0, 50).map((t, i) => (
|
|
<tr key={i}>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">
|
|
{t.starttime ? new Date(t.starttime * 1000).toLocaleString('de-DE') : '—'}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-400 text-xs">{t.type || 'vzdump'}</td>
|
|
<td className="px-3 py-2 text-gray-400">{t.id || '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
|
t.status === 'OK'
|
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
|
: 'bg-red-900/40 text-red-400 border border-red-700/50'
|
|
}`}>
|
|
{t.status || '—'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{failedTasks > 0 && (
|
|
<div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">
|
|
{failedTasks} fehlgeschlagene Backup(s) in den letzten 30 Tagen
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <div className="p-4 text-gray-500">Keine PBS-Daten verfuegbar</div>
|
|
|
|
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 }) => (
|
|
<div className={`flex-1 bg-[#1a2235] rounded-xl border p-3 flex flex-col items-center gap-1.5 ${highlight ? 'border-blue-500/50' : 'border-gray-700/40'}`}>
|
|
<div className={`text-xl ${iconColor}`}>{icon}</div>
|
|
<div className="text-xl font-bold text-white">{value}</div>
|
|
<div className="text-xs text-gray-400">{label}</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Titel */}
|
|
<div>
|
|
<div className="text-lg font-semibold text-white">Backup Server</div>
|
|
{pbs.version && <div className="text-xs text-gray-500 mt-0.5">PBS {pbs.version}</div>}
|
|
</div>
|
|
|
|
{/* Stat-Karten */}
|
|
<div className="flex gap-2">
|
|
<StatCard
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3" strokeWidth="2"/><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5v14a9 3 0 0018 0V5M3 12a9 3 0 0018 0"/></svg>}
|
|
value={pbs.datastores?.length || 0}
|
|
label="Datastores"
|
|
iconColor="text-orange-400"
|
|
/>
|
|
<StatCard
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8"/></svg>}
|
|
value={totalBackups}
|
|
label="Backups"
|
|
iconColor="text-blue-400"
|
|
/>
|
|
<StatCard
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>}
|
|
value={pbs.sync_jobs ?? 0}
|
|
label="Sync Jobs"
|
|
iconColor="text-green-400"
|
|
/>
|
|
<StatCard
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" strokeWidth="2"/><polyline points="12 6 12 12 16 14" strokeWidth="2"/></svg>}
|
|
value={runningTasks > 0 ? runningTasks : '—'}
|
|
label="Laufend"
|
|
iconColor={runningTasks > 0 ? "text-blue-400" : "text-gray-600"}
|
|
highlight={runningTasks > 0}
|
|
/>
|
|
</div>
|
|
|
|
{/* Datastores */}
|
|
{pbs.datastores?.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-white">Datastores</h3>
|
|
{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 (
|
|
<button
|
|
onClick={() => pbsAction(ds.name, action)}
|
|
disabled={loading[k]}
|
|
className={`flex-1 py-1.5 rounded text-sm font-semibold transition-all ${cls} disabled:opacity-50`}
|
|
>
|
|
{loading[k] ? '...' : fb === 'ok' ? 'Gestartet' : fb === 'err' ? 'Fehler' : label}
|
|
</button>
|
|
)
|
|
}
|
|
return (
|
|
<div key={ds.name} className="bg-[#1a2235] rounded-xl border border-gray-700/40 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
|
</svg>
|
|
<span className="text-white font-semibold">{ds.name}</span>
|
|
</div>
|
|
{ds.backup_count > 0 && <span className="text-sm text-gray-400">{ds.backup_count} Backups</span>}
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-xs text-gray-400 mb-1.5">
|
|
<span>{formatBytes(ds.used)} / {formatBytes(ds.total)}</span>
|
|
<span>{usedPct.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
|
</div>
|
|
</div>
|
|
{gc?.status && (
|
|
<div className="text-xs text-gray-500 flex gap-3">
|
|
<span>GC: <span className={gc.status === 'ok' ? 'text-green-400' : gc.status === 'running' ? 'text-blue-400' : 'text-gray-500'}>{gc.status}</span></span>
|
|
{gc.last_run > 0 && <span>Letzter Lauf: {formatTs(gc.last_run)}</span>}
|
|
{gc.duration > 0 && <span>({formatDuration(gc.duration)})</span>}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
<ActionBtn action="gc" label="GC" cls="bg-yellow-600 hover:bg-yellow-500 text-white" />
|
|
<ActionBtn action="verify" label="Verify" cls="bg-gray-600 hover:bg-gray-500 text-white" />
|
|
<ActionBtn action="prune" label="Prune" cls="bg-orange-700 hover:bg-orange-600 text-white" />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tasks */}
|
|
{pbs.tasks?.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-semibold text-white">Letzte Tasks</h3>
|
|
<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-4 py-2">Typ</th>
|
|
<th className="px-4 py-2">Start</th>
|
|
<th className="px-4 py-2">Dauer</th>
|
|
<th className="px-4 py-2 text-right">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700/50">
|
|
{pbs.tasks.map((t, i) => (
|
|
<tr key={i} className={`hover:bg-gray-700/20 ${t.running ? 'bg-blue-900/10' : ''}`}>
|
|
<td className="px-4 py-2 font-mono">
|
|
<span className={t.running ? 'text-blue-300 font-semibold' : 'text-gray-300'}>{t.type || '—'}</span>
|
|
{t.running && <span className="ml-2 text-[10px] font-bold px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30">RUNNING</span>}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-400">{formatTs(t.start_time)}</td>
|
|
<td className="px-4 py-2 text-gray-400">{t.running ? <span className="text-blue-400 animate-pulse">läuft...</span> : formatDuration(t.duration)}</td>
|
|
<td className={`px-4 py-2 text-right font-medium ${taskStatusColor(t.status)}`}>{t.status || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|