feat: Windows Agent Frontend Panel
- WindowsPanel.jsx: Uebersicht (CPU/RAM/Disk), Software (winget), System - Dashboard: Windows-Agents mit WIN-Badge, oeffnet WindowsPanel - Gleiche Navigation wie OPNsense/Proxmox (Sidebar-Menue)
This commit is contained in:
parent
8116b71b48
commit
4f71bd1520
586
frontend/src/components/WindowsPanel.jsx
Normal file
586
frontend/src/components/WindowsPanel.jsx
Normal file
@ -0,0 +1,586 @@
|
||||
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 {
|
||||
X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings,
|
||||
Download, Trash2, RefreshCw, Package, Search, Play,
|
||||
ShieldCheck, Bot, Cable, CalendarClock, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mainCategories = [
|
||||
{ id: 'uebersicht', label: 'Uebersicht' },
|
||||
{ id: 'software', label: 'Software' },
|
||||
{ id: 'system', label: 'System' },
|
||||
]
|
||||
|
||||
const subTabs = {
|
||||
software: [
|
||||
{ id: 'installed', label: 'Installiert', icon: Package },
|
||||
{ id: 'winget', label: 'winget Updates', icon: RefreshCw },
|
||||
{ id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck },
|
||||
],
|
||||
system: [
|
||||
{ id: 'services', label: 'Dienste', icon: Settings },
|
||||
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
|
||||
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
|
||||
{ id: 'agent', label: 'Agent', icon: Bot },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Panel Shell ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Panel({ onClose, children }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-stretch justify-end bg-black/50">
|
||||
<div className="w-full max-w-5xl bg-gray-900 flex flex-col shadow-2xl border-l border-gray-700 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CustomerAssign ────────────────────────────────────────────────────────────
|
||||
|
||||
function CustomerAssign({ agent, customers, current, onReload }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [val, setVal] = useState(current?.id?.toString() || '')
|
||||
|
||||
const save = async () => {
|
||||
if (val === '') await api.unassignAgentCustomer(agent.id)
|
||||
else await api.assignAgentCustomer(agent.id, parseInt(val))
|
||||
setEditing(false)
|
||||
if (onReload) onReload()
|
||||
}
|
||||
|
||||
if (!editing) return (
|
||||
<button onClick={() => setEditing(true)}
|
||||
className="text-xs text-gray-500 hover:text-orange-400 transition-colors mt-0.5 flex items-center gap-1">
|
||||
{current ? `${current.number} — ${current.name}` : 'Kein Kunde'}
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<select value={val} onChange={e => setVal(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-white focus:outline-none">
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map(c => <option key={c.id} value={c.id}>{c.number} — {c.name}</option>)}
|
||||
</select>
|
||||
<button onClick={save} className="text-green-400 text-xs">OK</button>
|
||||
<button onClick={() => setEditing(false)} className="text-gray-500 text-xs">Abbrechen</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WindowsPanel({ agentId, customers, onClose, onReload }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mainTab, setMainTab] = useState('uebersicht')
|
||||
const [subTab, setSubTab] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setMainTab('uebersicht')
|
||||
setSubTab(null)
|
||||
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
const iv = setInterval(() => api.getAgent(agentId).then(setData), 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [agentId, data])
|
||||
|
||||
if (loading) return <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
||||
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
||||
|
||||
const { agent, system_data: sys, status } = data
|
||||
const win = sys?.windows
|
||||
const cust = agent.customer_id ? customers.find(c => c.id === agent.customer_id) : null
|
||||
const currentSubs = mainTab !== 'uebersicht' ? (subTabs[mainTab] || []) : []
|
||||
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
|
||||
|
||||
const handleMainTab = (id) => {
|
||||
setMainTab(id)
|
||||
setSubTab(subTabs[id]?.[0]?.id ?? null)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (mainTab === 'uebersicht') return <OverviewTab win={win} agent={agent} status={status} />
|
||||
const t = activeSubTab
|
||||
if (t === 'installed') return <WingetListTab agentId={agentId} />
|
||||
if (t === 'winget') return <WingetUpgradeTab agentId={agentId} />
|
||||
if (t === 'wupdates') return <WindowsUpdatesTab agentId={agentId} />
|
||||
if (t === 'services') return <ServicesTab win={win} />
|
||||
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={agent} />
|
||||
if (t === 'tasks') return <TasksTab agentId={agentId} agentName={agent.name} />
|
||||
if (t === 'agent') return <AgentInfoTab agent={agent} status={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">
|
||||
<Monitor className="w-5 h-5 text-blue-400" />
|
||||
<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">
|
||||
{win?.hostname || agent.hostname}
|
||||
{win?.os_version && <span> · {win.os_version}</span>}
|
||||
{win?.domain && <span> · {win.domain}</span>}
|
||||
<span> · IP: {agent.ip}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Agent "${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="Agent 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)}
|
||||
className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
|
||||
mainTab === cat.id
|
||||
? 'text-blue-400 border-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-200 border-transparent'
|
||||
}`}>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content + Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sub-Navigation */}
|
||||
{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-blue-400 bg-blue-400/5 border-l-2 border-blue-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>
|
||||
)}
|
||||
|
||||
{/* Inhalt */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Uebersicht ────────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ win, agent, status }) {
|
||||
const fmt = (b) => {
|
||||
if (!b) return '—'
|
||||
if (b >= 1073741824) return `${(b / 1073741824).toFixed(1)} GB`
|
||||
return `${(b / 1048576).toFixed(0)} MB`
|
||||
}
|
||||
const fmtUptime = (s) => {
|
||||
if (!s) return '—'
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
if (!win) return <div className="text-gray-500">Keine Systemdaten — Agent noch nicht verbunden oder erster Heartbeat ausstehend</div>
|
||||
|
||||
const memUsed = win.memory?.total_bytes - win.memory?.available_bytes
|
||||
const memPct = win.memory?.used_percent ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats-Karten */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard icon={Cpu} label="CPU-Last" value={`${win.cpu?.load_percent?.toFixed(1) ?? '—'}%`} sub={win.cpu?.model?.split(' ').slice(-2).join(' ')} color="text-blue-400" pct={win.cpu?.load_percent} />
|
||||
<StatCard icon={MemoryStick} label="RAM" value={`${memPct.toFixed(0)}%`} sub={`${fmt(memUsed)} / ${fmt(win.memory?.total_bytes)}`} color="text-purple-400" pct={memPct} />
|
||||
<StatCard icon={Server} label="Uptime" value={fmtUptime(win.uptime_seconds)} sub="seit letztem Start" color="text-green-400" />
|
||||
<StatCard icon={Monitor} label="OS" value={win.os_version?.replace('Windows ', 'Win ') || '—'} sub={win.os_build ? `Build ${win.os_build}` : ''} color="text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* CPU-Details */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Prozessor</div>
|
||||
<div className="text-sm text-white mb-2">{win.cpu?.model || '—'}</div>
|
||||
<div className="flex gap-6 text-xs text-gray-400">
|
||||
<span>{win.cpu?.cores ?? '—'} Kerne</span>
|
||||
<span>{win.cpu?.logical_cores ?? '—'} logische Kerne</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<UsageBar pct={win.cpu?.load_percent ?? 0} color="bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arbeitsspeicher */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Arbeitsspeicher</div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-300">Belegt: {fmt(memUsed)}</span>
|
||||
<span className="text-gray-500">Gesamt: {fmt(win.memory?.total_bytes)}</span>
|
||||
</div>
|
||||
<UsageBar pct={memPct} color={memPct > 85 ? 'bg-red-500' : memPct > 65 ? 'bg-yellow-500' : 'bg-purple-500'} />
|
||||
</div>
|
||||
|
||||
{/* Festplatten */}
|
||||
{win.disks?.length > 0 && (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="text-xs text-gray-500 mb-3 font-semibold uppercase tracking-wide">Laufwerke</div>
|
||||
<div className="space-y-3">
|
||||
{win.disks.map(d => (
|
||||
<div key={d.drive}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white font-mono">{d.drive}</span>
|
||||
<span className="text-gray-400 text-xs">{fmt(d.total_bytes - d.free_bytes)} / {fmt(d.total_bytes)} ({d.used_percent?.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<UsageBar pct={d.used_percent} color={d.used_percent > 90 ? 'bg-red-500' : d.used_percent > 75 ? 'bg-yellow-500' : 'bg-green-600'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Software / winget ─────────────────────────────────────────────────────────
|
||||
|
||||
function WingetListTab({ agentId }) {
|
||||
const [output, setOutput] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.execCommand(agentId, 'winget list --accept-source-agreements', 30)
|
||||
setOutput(res?.output || res?.data?.output || '')
|
||||
} catch (e) {
|
||||
setOutput(`Fehler: ${e.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [agentId])
|
||||
|
||||
const lines = output ? output.split('\n').filter(l => l.trim() && !l.startsWith('-')) : []
|
||||
const filtered = search ? lines.filter(l => l.toLowerCase().includes(search.toLowerCase())) : lines
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Software suchen..."
|
||||
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-gray-500 text-sm">Lade Liste...</div>
|
||||
) : (
|
||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
||||
{filtered.join('\n') || 'Keine Eintraege'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WingetUpgradeTab({ agentId }) {
|
||||
const [output, setOutput] = useState(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [upgradeAll, setUpgradeAll] = useState(false)
|
||||
|
||||
const check = async () => {
|
||||
setRunning(true)
|
||||
try {
|
||||
const res = await api.execCommand(agentId, 'winget upgrade --accept-source-agreements', 60)
|
||||
setOutput(res?.output || res?.data?.output || '')
|
||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
||||
finally { setRunning(false) }
|
||||
}
|
||||
|
||||
const doUpgradeAll = async () => {
|
||||
if (!confirm('Alle verfügbaren winget-Updates installieren?')) return
|
||||
setUpgradeAll(true)
|
||||
try {
|
||||
const res = await api.execCommand(agentId,
|
||||
'winget upgrade --all --silent --accept-package-agreements --accept-source-agreements', 600)
|
||||
setOutput(res?.output || res?.data?.output || '')
|
||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
||||
finally { setUpgradeAll(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { check() }, [agentId])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={check} disabled={running}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${running ? 'animate-spin' : ''}`} />
|
||||
Pruefen
|
||||
</button>
|
||||
<button onClick={doUpgradeAll} disabled={running || upgradeAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{upgradeAll ? 'Installiere...' : 'Alle updaten'}
|
||||
</button>
|
||||
</div>
|
||||
{(running || upgradeAll) && <div className="text-gray-400 text-sm">Bitte warten...</div>}
|
||||
{output && (
|
||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WindowsUpdatesTab({ agentId }) {
|
||||
const [output, setOutput] = useState(null)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
|
||||
const script = `$s = New-Object -ComObject Microsoft.Update.Session; $r = $s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); $r.Updates | ForEach-Object { Write-Output "KB$($_.KBArticleIDs[0]) — $($_.Title)" }; Write-Output "---"; Write-Output "Gesamt: $($r.Updates.Count) Updates ausstehend"`
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`, 120)
|
||||
setOutput(res?.output || res?.data?.output || 'Keine Ausgabe')
|
||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
||||
finally { setChecking(false) }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!confirm('Windows Updates installieren? Der PC kann danach neu starten.')) return
|
||||
setInstalling(true)
|
||||
setOutput('Updates werden installiert — das kann mehrere Minuten dauern...')
|
||||
try {
|
||||
const installScript = `$s=New-Object -ComObject Microsoft.Update.Session; $r=$s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); if($r.Updates.Count -eq 0){Write-Output "Keine Updates verfuegbar"; exit}; $d=$s.CreateUpdateDownloader(); $d.Updates=$r.Updates; $d.Download(); $i=$s.CreateUpdateInstaller(); $i.Updates=$r.Updates; $ir=$i.Install(); Write-Output "Installiert: $($r.Updates.Count), ResultCode: $($ir.ResultCode)"`
|
||||
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${installScript.replace(/"/g, '\\"')}"`, 1800)
|
||||
setOutput(res?.output || res?.data?.output || '')
|
||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
||||
finally { setInstalling(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { check() }, [agentId])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={check} disabled={checking || installing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? 'animate-spin' : ''}`} />
|
||||
Pruefen
|
||||
</button>
|
||||
<button onClick={install} disabled={checking || installing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
{installing ? 'Installiere...' : 'Jetzt installieren'}
|
||||
</button>
|
||||
</div>
|
||||
{output && (
|
||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── System-Tabs ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicesTab({ win }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const services = win?.services || []
|
||||
const filtered = search
|
||||
? services.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.display_name?.toLowerCase().includes(search.toLowerCase()))
|
||||
: services
|
||||
|
||||
if (services.length === 0) return <div className="text-gray-500 text-sm">Keine Dienstdaten verfuegbar</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Dienste suchen..."
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
||||
<div className="space-y-0.5">
|
||||
{filtered.map(s => (
|
||||
<div key={s.name} className="flex items-center gap-2.5 px-2 py-1 rounded hover:bg-gray-800/50">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
s.status === 'running' ? 'bg-green-400' :
|
||||
s.status === 'stopped' ? 'bg-gray-600' : 'bg-yellow-400'
|
||||
}`} />
|
||||
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{s.display_name || s.name}</span>
|
||||
<span className="text-[10px] text-gray-600 flex-shrink-0">{s.start_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TunnelTab({ agentId, agent }) {
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [showScript, setShowScript] = useState(false)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowTerminal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
|
||||
<Play className="w-4 h-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button onClick={() => setShowScript(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
|
||||
<Settings className="w-4 h-4" />
|
||||
Script
|
||||
</button>
|
||||
</div>
|
||||
{showTerminal && <TerminalModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowTerminal(false)} />}
|
||||
{showScript && <ScriptModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowScript(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TasksTab({ agentId, agentName }) {
|
||||
const [cmd, setCmd] = useState('')
|
||||
const [output, setOutput] = useState(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
|
||||
const run = async () => {
|
||||
if (!cmd.trim()) return
|
||||
setRunning(true)
|
||||
try {
|
||||
const res = await api.execCommand(agentId, cmd, 60)
|
||||
setOutput(res?.output || res?.data?.output || '')
|
||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
||||
finally { setRunning(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input value={cmd} onChange={e => setCmd(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && run()}
|
||||
placeholder="Befehl (PowerShell oder cmd)"
|
||||
className="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white font-mono focus:outline-none focus:border-blue-500" />
|
||||
<button onClick={run} disabled={running || !cmd.trim()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 rounded text-sm text-white transition-colors">
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{running ? 'Laeuft...' : 'Ausfuehren'}
|
||||
</button>
|
||||
</div>
|
||||
{output !== null && (
|
||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-80 overflow-y-auto">
|
||||
{output || '(keine Ausgabe)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentInfoTab({ agent, status }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-2">
|
||||
{[
|
||||
['Agent-ID', agent.id],
|
||||
['Name', agent.name],
|
||||
['Hostname', agent.hostname],
|
||||
['IP', agent.ip],
|
||||
['Version', agent.agent_version || '—'],
|
||||
['Plattform', agent.platform],
|
||||
['Status', status],
|
||||
['Registriert', new Date(agent.registered_at).toLocaleString('de-DE')],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex gap-4">
|
||||
<span className="text-xs text-gray-500 w-28 shrink-0">{k}</span>
|
||||
<span className="text-xs text-gray-300 font-mono break-all">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hilfskomponenten ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, color, pct }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-gray-600 mt-0.5 truncate">{sub}</div>}
|
||||
{pct !== undefined && <UsageBar pct={pct} color={color.replace('text-', 'bg-')} className="mt-2" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageBar({ pct, color, className = '' }) {
|
||||
return (
|
||||
<div className={`w-full bg-gray-700 rounded-full h-1.5 ${className}`}>
|
||||
<div className={`h-1.5 rounded-full transition-all ${color}`}
|
||||
style={{ width: `${Math.min(100, pct ?? 0).toFixed(0)}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,7 +3,8 @@ import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import AgentPanel from '../components/AgentPanel'
|
||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||
import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
import WindowsPanel from '../components/WindowsPanel'
|
||||
import { Server, HardDrive, Users, Activity, AlertTriangle, Monitor } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [agents, setAgents] = useState([])
|
||||
@ -11,7 +12,7 @@ export default function Dashboard() {
|
||||
const [agentDetails, setAgentDetails] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox'
|
||||
const [selectedType, setSelectedType] = useState(null) // 'device' | 'proxmox' | 'windows'
|
||||
|
||||
const reload = () => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
@ -34,6 +35,11 @@ export default function Dashboard() {
|
||||
const d = agentDetails[agentId]
|
||||
return d?.system_data?.proxmox?.available === true
|
||||
}
|
||||
const getAgentType = (agent) => {
|
||||
if (isProxmox(agent.id)) return 'proxmox'
|
||||
if (agent.platform === 'windows') return 'windows'
|
||||
return 'device'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
@ -89,7 +95,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800">
|
||||
{customer.agents.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -105,14 +111,14 @@ export default function Dashboard() {
|
||||
{agents
|
||||
.filter((a) => !a.customer_id)
|
||||
.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel — Geraet-Detail */}
|
||||
{/* Detail Panels */}
|
||||
{selectedId && selectedType === 'proxmox' && (
|
||||
<ProxmoxPanel
|
||||
agentId={selectedId}
|
||||
@ -121,7 +127,15 @@ export default function Dashboard() {
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
{selectedId && selectedType !== 'proxmox' && (
|
||||
{selectedId && selectedType === 'windows' && (
|
||||
<WindowsPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
{selectedId && selectedType === 'device' && (
|
||||
<AgentPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
@ -161,8 +175,12 @@ function AgentRow({ agent, isPve, onClick, selected }) {
|
||||
<StatusBadge status={agent.status} size="dot" />
|
||||
<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 className={`text-[10px] font-mono px-1 rounded ${
|
||||
agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' :
|
||||
agent.platform === 'windows' ? 'bg-sky-900/50 text-sky-400' :
|
||||
'bg-orange-900/50 text-orange-400'
|
||||
}`}>
|
||||
{agent.platform === 'linux' ? 'LNX' : agent.platform === 'windows' ? 'WIN' : 'OPN'}
|
||||
</span>
|
||||
{agent.name}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user