Linux/Proxmox Agent + Plattform-Updater + Proxmox Frontend
- agent-linux/: Kompletter Linux/Proxmox RMM Agent - Collectors: CPU, Memory, Disk, Network, Services, Updates, Proxmox (VMs, Container, Storage, ZFS), Backups - Backup Collector: Jobs nach Node gefiltert (Cluster-aware) - WebSocket: Tunnel-Support, writeMux fuer stabile Verbindungen - Systemd Service Template - updater/: Plattform-unabhaengiger Updater (FreeBSD + Linux) - runtime.GOOS erkennt Plattform automatisch - FreeBSD: /usr/local/rmm/, service rmm_agent - Linux: /usr/local/bin/, /etc/rmm/, systemctl rmm-agent - Firmware-Download mit ?platform= Parameter - frontend/: Proxmox-Bereich - ProxmoxServers.jsx: Eigene Seite fuer PVE Server mit VM/CT Counts - ProxmoxPanel.jsx: Detail-Panel mit Uebersicht, VMs, Container, Storage, ZFS, Tunnel, Dienste, Updates, Backups - Agents.jsx: Filtert Linux-Agents raus (nur OPNsense) - Sidebar: Proxmox Navigationspunkt - Installationsanleitung mit rmm-agent-linux + rmm-updater-linux - backend: Platform-Feld fuer Agents, Firmware multi-platform
This commit is contained in:
@@ -63,6 +63,10 @@ export default function Agents() {
|
||||
const detail = agentDetails[a.id]
|
||||
const sys = detail?.system_data
|
||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||
|
||||
// Nur OPNsense/FreeBSD Agents anzeigen
|
||||
if (a.platform === 'linux') return null
|
||||
|
||||
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
||||
@@ -85,7 +89,7 @@ export default function Agents() {
|
||||
gateways,
|
||||
sys,
|
||||
}
|
||||
})
|
||||
}).filter(Boolean) // Entferne null Werte
|
||||
}, [agents, agentDetails, customerMap])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -138,12 +142,12 @@ export default function Agents() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Firewalls ({agents.length})</h1>
|
||||
<h1 className="text-xl font-bold text-white">Geraete ({agents.length})</h1>
|
||||
<button
|
||||
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Firewall hinzufuegen
|
||||
<Plus className="w-4 h-4" /> Geraet hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +196,14 @@ export default function Agents() {
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={`text-[10px] font-mono px-1 rounded ${a.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||
{a.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||
</span>
|
||||
{a.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.version || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||
@@ -227,7 +238,7 @@ export default function Agents() {
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
|
||||
{search ? 'Keine Treffer' : 'Keine Geraete registriert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,9 +254,9 @@ export default function Agents() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Firewall hinzufuegen Dialog */}
|
||||
{/* Geraet hinzufuegen Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddFirewallDialog
|
||||
<AddDeviceDialog
|
||||
customers={customers}
|
||||
result={addResult}
|
||||
onAdd={async (name, custId) => {
|
||||
@@ -260,7 +271,7 @@ export default function Agents() {
|
||||
)
|
||||
}
|
||||
|
||||
function AddFirewallDialog({ customers, result, onAdd, onClose }) {
|
||||
function AddDeviceDialog({ customers, result, onAdd, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [custId, setCustId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -299,7 +310,7 @@ function AddFirewallDialog({ customers, result, onAdd, onClose }) {
|
||||
# 1. Ins RMM-Verzeichnis wechseln:
|
||||
cd /Users/cynfo/.openclaw/workspace/rmm
|
||||
|
||||
# 2. Agent + Updater + Installer auf die Firewall kopieren:
|
||||
# 2. Agent + Updater + Installer auf das Geraet kopieren:
|
||||
scp build/rmm-agent build/rmm-updater root@<FIREWALL-IP>:/tmp/
|
||||
scp opnsense-plugin/install.sh root@<FIREWALL-IP>:/tmp/
|
||||
|
||||
@@ -316,7 +327,7 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Firewall hinzufuegen</h2>
|
||||
<h2 className="text-lg font-semibold text-white">Geraet hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
@@ -354,13 +365,13 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
||||
disabled={submitting}
|
||||
className="w-full py-2 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{submitting ? 'Wird angelegt...' : 'Firewall anlegen'}
|
||||
{submitting ? 'Wird angelegt...' : 'Geraet anlegen'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||
Firewall "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
Geraet "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,23 +2,39 @@ import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import AgentPanel from '../components/AgentPanel'
|
||||
import { Server, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||
import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [agentDetails, setAgentDetails] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox'
|
||||
|
||||
const reload = () => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
// Lade Details fuer Typ-Erkennung
|
||||
;(a || []).forEach((agent) => {
|
||||
if (!agentDetails[agent.id]) {
|
||||
api.getAgent(agent.id).then((d) => {
|
||||
setAgentDetails((prev) => ({ ...prev, [agent.id]: d }))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const isProxmox = (agentId) => {
|
||||
const d = agentDetails[agentId]
|
||||
return d?.system_data?.proxmox?.available === true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
@@ -48,8 +64,9 @@ export default function Dashboard() {
|
||||
<h1 className="text-xl font-bold text-white">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Server} label="Firewalls" value={agents.length} color="text-blue-400" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard icon={Server} label="Geraete" value={agents.filter(a => !isProxmox(a.id)).length} color="text-blue-400" />
|
||||
<StatCard icon={HardDrive} label="Proxmox" value={agents.filter(a => isProxmox(a.id)).length} color="text-purple-400" />
|
||||
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
|
||||
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
|
||||
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
|
||||
@@ -67,12 +84,12 @@ export default function Dashboard() {
|
||||
<span className="text-orange-400">{customer.number}</span> — {customer.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
|
||||
{customer.agents.length} {customer.agents.length === 1 ? 'Geraet' : 'Geraete'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800">
|
||||
{customer.agents.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,19 +105,27 @@ export default function Dashboard() {
|
||||
{agents
|
||||
.filter((a) => !a.customer_id)
|
||||
.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedId && (
|
||||
{/* Detail Panel — Geraet-Detail */}
|
||||
{selectedId && selectedType === 'proxmox' && (
|
||||
<ProxmoxPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
{selectedId && selectedType !== 'proxmox' && (
|
||||
<AgentPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
@@ -122,7 +147,7 @@ function StatCard({ icon: Icon, label, value, color }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRow({ agent, onClick, selected }) {
|
||||
function AgentRow({ agent, isPve, onClick, selected }) {
|
||||
const lastHB = agent.last_heartbeat
|
||||
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
||||
: '—'
|
||||
@@ -135,7 +160,12 @@ function AgentRow({ agent, onClick, selected }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={agent.status} size="dot" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{agent.name}</div>
|
||||
<div className="text-sm text-white inline-flex items-center gap-2">
|
||||
<span className={`text-[10px] font-mono px-1 rounded ${agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||
{agent.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||
</span>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{agent.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,14 +102,19 @@ export default function Firmware() {
|
||||
|
||||
if (loading) return <div className="text-gray-500">Laden...</div>
|
||||
|
||||
// Fuer jetzt: alle Agents sind freebsd
|
||||
const freebsdFw = getFwForPlatform('freebsd')
|
||||
const getFwForAgent = (agent) => {
|
||||
const p = agent.platform || 'freebsd'
|
||||
return getFwForPlatform(p)
|
||||
}
|
||||
|
||||
const needsUpdate = (agent) => {
|
||||
if (!freebsdFw) return false
|
||||
return agent.agent_version !== freebsdFw.version
|
||||
const fw = getFwForAgent(agent)
|
||||
if (!fw) return false
|
||||
return agent.agent_version !== fw.version
|
||||
}
|
||||
|
||||
const hasAnyFirmware = PLATFORMS.some(p => getFwForPlatform(p.id))
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Agent-Firmware</h1>
|
||||
@@ -201,7 +206,7 @@ export default function Firmware() {
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2>
|
||||
{freebsdFw && (
|
||||
{hasAnyFirmware && (
|
||||
<button
|
||||
onClick={requestAll}
|
||||
className="flex items-center gap-2 bg-orange-600/20 hover:bg-orange-600/30 text-orange-400 px-3 py-1.5 rounded text-xs transition-colors border border-orange-600/30"
|
||||
@@ -216,6 +221,10 @@ export default function Firmware() {
|
||||
{agents.map((agent) => {
|
||||
const outdated = needsUpdate(agent)
|
||||
const pending = agent.update_requested
|
||||
const agentPlatform = agent.platform || 'freebsd'
|
||||
const fw = getFwForAgent(agent)
|
||||
const platformLabel = agentPlatform === 'linux' ? 'Linux' : 'FreeBSD'
|
||||
const platformColor = agentPlatform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'
|
||||
return (
|
||||
<div key={agent.id} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -225,18 +234,20 @@ export default function Firmware() {
|
||||
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
|
||||
}`} />
|
||||
<span className="text-white text-sm font-medium truncate">{agent.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/50 text-gray-500">FreeBSD</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${platformColor}`}>{platformLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Version: <span className={outdated ? 'text-yellow-400' : 'text-gray-400'}>{agent.agent_version || '--'}</span>
|
||||
{fw && <span className="text-gray-600 ml-1">(aktuell: v{fw.version})</span>}
|
||||
{!fw && <span className="text-gray-600 ml-1">(keine Firmware fuer {platformLabel})</span>}
|
||||
</span>
|
||||
{pending && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
Update ausstehend
|
||||
</span>
|
||||
)}
|
||||
{!outdated && freebsdFw && agent.agent_version && (
|
||||
{!outdated && fw && agent.agent_version && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
Aktuell
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||
import { Search, ChevronUp, ChevronDown, Plus, Copy, Check, X } from 'lucide-react'
|
||||
|
||||
export default function ProxmoxServers() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sortKey, setSortKey] = useState('customer')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [addResult, setAddResult] = useState(null)
|
||||
|
||||
const reload = () => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
const iv = setInterval(reload, 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const m = {}
|
||||
customers.forEach((c) => (m[c.id] = c))
|
||||
return m
|
||||
}, [customers])
|
||||
|
||||
// Enrich agents with system data summaries
|
||||
const [agentDetails, setAgentDetails] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
agents.forEach((a) => {
|
||||
if (!agentDetails[a.id]) {
|
||||
api.getAgent(a.id).then((d) => {
|
||||
setAgentDetails((prev) => ({ ...prev, [a.id]: d }))
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [agents])
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = useMemo(() => {
|
||||
return agents.map((a) => {
|
||||
const detail = agentDetails[a.id]
|
||||
const sys = detail?.system_data
|
||||
const proxmox = sys?.proxmox
|
||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||
|
||||
// Nur Proxmox-Agents anzeigen
|
||||
if (!proxmox?.available) return null
|
||||
|
||||
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
||||
: null
|
||||
const ramPct = sys?.memory?.total_bytes > 0
|
||||
? (sys.memory.used_bytes / sys.memory.total_bytes * 100)
|
||||
: null
|
||||
|
||||
const 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
|
||||
|
||||
return {
|
||||
...a,
|
||||
customer: cust,
|
||||
customerName: cust?.name || '',
|
||||
customerNumber: cust?.number || '',
|
||||
cpuPct: sys?.cpu?.usage_percent ?? null,
|
||||
ramPct,
|
||||
diskPct,
|
||||
uptime: sys?.uptime_seconds || 0,
|
||||
pveVersion: proxmox.version || '',
|
||||
vms: `${vmsRunning}/${vmsTotal}`,
|
||||
vmsRunning,
|
||||
vmsTotal,
|
||||
containers: `${ctRunning}/${ctTotal}`,
|
||||
ctRunning,
|
||||
ctTotal,
|
||||
sys,
|
||||
proxmox,
|
||||
}
|
||||
}).filter(Boolean) // Entferne null Werte
|
||||
}, [agents, agentDetails, customerMap])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = enriched
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter((a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.ip.toLowerCase().includes(q) ||
|
||||
a.hostname.toLowerCase().includes(q) ||
|
||||
a.customerName.toLowerCase().includes(q) ||
|
||||
a.customerNumber.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
let va, vb
|
||||
switch (sortKey) {
|
||||
case 'customer': va = a.customerNumber; vb = b.customerNumber; break
|
||||
case 'name': va = a.name; vb = b.name; break
|
||||
case 'ip': va = a.ip; vb = b.ip; break
|
||||
case 'version': va = a.pveVersion; vb = b.pveVersion; break
|
||||
case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime
|
||||
case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1)
|
||||
case 'ram': return sortDir === 'asc' ? (a.ramPct ?? 999) - (b.ramPct ?? 999) : (b.ramPct ?? -1) - (a.ramPct ?? -1)
|
||||
case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1)
|
||||
case 'vms': return sortDir === 'asc' ? a.vmsTotal - b.vmsTotal : b.vmsTotal - a.vmsTotal
|
||||
case 'containers': return sortDir === 'asc' ? a.ctTotal - b.ctTotal : b.ctTotal - a.ctTotal
|
||||
case 'status': va = a.status; vb = b.status; break
|
||||
default: va = a.name; vb = b.name
|
||||
}
|
||||
if (typeof va === 'string') {
|
||||
const cmp = va.localeCompare(vb)
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return list
|
||||
}, [enriched, search, sortKey, sortDir])
|
||||
|
||||
const SortHeader = ({ label, k, className = '' }) => (
|
||||
<th
|
||||
className={`px-3 py-2 font-medium cursor-pointer hover:text-gray-300 select-none ${className}`}
|
||||
onClick={() => toggleSort(k)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Proxmox Server ({enriched.length})</h1>
|
||||
<button
|
||||
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Server hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Name, IP, Kunde)..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-x-auto">
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||
<th className="px-3 py-2 font-medium w-8"></th>
|
||||
<SortHeader label="KUNDE" k="customer" />
|
||||
<SortHeader label="NAME" k="name" />
|
||||
<th className="px-3 py-2 font-medium">HOSTNAME</th>
|
||||
<SortHeader label="PVE VERSION" k="version" />
|
||||
<th className="px-3 py-2 font-medium">IP</th>
|
||||
<SortHeader label="UPTIME" k="uptime" />
|
||||
<SortHeader label="CPU" k="cpu" />
|
||||
<SortHeader label="RAM" k="ram" />
|
||||
<SortHeader label="DISK" k="disk" />
|
||||
<SortHeader label="VMS" k="vms" />
|
||||
<SortHeader label="CONTAINER" k="containers" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{filtered.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setSelectedId(a.id)}
|
||||
className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-2"><StatusBadge status={a.status} size="dot" /></td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.pveVersion || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.uptime ? formatUptime(a.uptime) : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.cpuPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.ramPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.diskPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<VmBadge running={a.vmsRunning} total={a.vmsTotal} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<VmBadge running={a.ctRunning} total={a.ctTotal} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{search ? 'Keine Treffer' : 'Keine Proxmox Server registriert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedId && (
|
||||
<ProxmoxPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Server hinzufuegen Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddServerDialog
|
||||
customers={customers}
|
||||
result={addResult}
|
||||
onAdd={async (name, custId) => {
|
||||
const res = await api.preRegisterAgent(name, custId)
|
||||
setAddResult(res)
|
||||
reload()
|
||||
}}
|
||||
onClose={() => { setShowAddDialog(false); setAddResult(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddServerDialog({ customers, result, onAdd, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [custId, setCustId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [copied, setCopied] = useState(null)
|
||||
|
||||
const BACKEND_URL = 'https://192.168.85.13:8443'
|
||||
const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('Name ist erforderlich'); return }
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onAdd(name.trim(), custId ? parseInt(custId) : null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const copyText = (text, id) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(id)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const CopyBtn = ({ text, id }) => (
|
||||
<button onClick={() => copyText(text, id)} className="ml-2 text-gray-500 hover:text-orange-400 transition-colors">
|
||||
{copied === id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const installCmd = `# Proxmox/Linux Server Installation:
|
||||
|
||||
# 1. Agent + Updater auf den Server kopieren:
|
||||
scp build/rmm-agent-linux build/rmm-updater-linux root@<SERVER-IP>:/tmp/
|
||||
|
||||
# 2. Installation auf dem Server:
|
||||
ssh root@<SERVER-IP> 'bash -s' << 'INSTALL'
|
||||
mkdir -p /usr/local/bin /etc/rmm
|
||||
|
||||
cp /tmp/rmm-agent-linux /usr/local/bin/rmm-agent
|
||||
cp /tmp/rmm-updater-linux /usr/local/bin/rmm-updater
|
||||
chmod +x /usr/local/bin/rmm-agent /usr/local/bin/rmm-updater
|
||||
|
||||
# Config erstellen:
|
||||
cat > /etc/rmm/config.yaml << EOF
|
||||
backend_url: ${BACKEND_URL}
|
||||
api_key: ${API_KEY}
|
||||
agent_name: ${name || '<NAME>'}
|
||||
heartbeat_interval: 30
|
||||
insecure: true
|
||||
EOF
|
||||
|
||||
# Agent Service:
|
||||
cat > /etc/systemd/system/rmm-agent.service << EOF
|
||||
[Unit]
|
||||
Description=RMM Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/rmm-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Updater Service:
|
||||
cat > /etc/systemd/system/rmm-updater.service << EOF
|
||||
[Unit]
|
||||
Description=RMM Updater
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/rmm-updater
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now rmm-agent rmm-updater
|
||||
INSTALL`
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Server hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{!result ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. PVE-KUNDE.intra.example.net"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Kunde (optional)</label>
|
||||
<select
|
||||
value={custId}
|
||||
onChange={e => setCustId(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500"
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-2 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{submitting ? 'Wird angelegt...' : 'Server anlegen'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||
Server "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white mb-2">Installationsanleitung</h3>
|
||||
<div className="bg-gray-950 rounded p-3 text-xs font-mono text-gray-300 whitespace-pre-wrap relative">
|
||||
{installCmd}
|
||||
<button
|
||||
onClick={() => copyText(installCmd, 'install')}
|
||||
className="absolute top-2 right-2 text-gray-600 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
{copied === 'install' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Backend URL:</span>
|
||||
<span className="text-white ml-2">{BACKEND_URL}</span>
|
||||
<CopyBtn text={BACKEND_URL} id="url" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">API Key:</span>
|
||||
<span className="text-white ml-2 font-mono text-xs">{API_KEY}</span>
|
||||
<CopyBtn text={API_KEY} id="key" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Der Agent verbindet sich nach der Installation automatisch mit dem Backend.
|
||||
Hostname und IP werden beim ersten Heartbeat aktualisiert.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VmBadge({ running, total }) {
|
||||
if (total === 0) return <span className="text-gray-600 text-xs">—</span>
|
||||
return (
|
||||
<span className="text-xs">
|
||||
<span className="text-green-400">{running}</span>
|
||||
<span className="text-gray-500">/{total}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniBar({ value }) {
|
||||
if (value === null || value === undefined) return <span className="text-gray-600 text-xs">—</span>
|
||||
const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1.5 bg-gray-800 rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${color}`} style={{ width: `${Math.min(value, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{value.toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export default function Tunnels() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Aktive Tunnel</h1>
|
||||
<span className="text-sm text-gray-500">
|
||||
{allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Firewall(s)
|
||||
{allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Geraet(e)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -104,7 +104,7 @@ export default function Tunnels() {
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Firewall, IP, Ziel, Port)..."
|
||||
placeholder="Suchen (Geraet, IP, Ziel, Port)..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
@@ -114,7 +114,7 @@ export default function Tunnels() {
|
||||
{error && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Lade Tunnel aller Firewalls...</div>
|
||||
<div className="text-gray-500">Lade Tunnel aller Geraete...</div>
|
||||
) : (() => {
|
||||
const filtered = search
|
||||
? allTunnels.filter((t) => {
|
||||
@@ -131,7 +131,7 @@ export default function Tunnels() {
|
||||
<Cable className="w-10 h-10 text-gray-700 mx-auto mb-3" />
|
||||
<div className="text-gray-500">Keine aktiven Tunnel</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Tunnel koennen ueber die Firewall-Detailansicht erstellt werden
|
||||
Tunnel koennen ueber die Geraete-Detailansicht erstellt werden
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -139,7 +139,7 @@ export default function Tunnels() {
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||
<th className="px-4 py-2.5 font-medium">Firewall</th>
|
||||
<th className="px-4 py-2.5 font-medium">Geraet</th>
|
||||
<th className="px-4 py-2.5 font-medium">Ziel</th>
|
||||
<th className="px-4 py-2.5 font-medium">Lokaler Zugang</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
|
||||
Reference in New Issue
Block a user