380 lines
17 KiB
JavaScript
380 lines
17 KiB
JavaScript
import { useEffect, useState, useRef } from 'react'
|
|
import api from '../api/client'
|
|
import { Upload, RefreshCw, CheckCircle, AlertTriangle, ArrowUpCircle, X, Monitor, Server, Cpu, Package, Copy, Check } from 'lucide-react'
|
|
import { copyToClipboard } from '../utils/clipboard'
|
|
|
|
const PLATFORMS = [
|
|
{ id: 'freebsd', label: 'FreeBSD / OPNsense', icon: Server },
|
|
{ id: 'linux', label: 'Linux', icon: Monitor },
|
|
{ id: 'windows', label: 'Windows', icon: Cpu },
|
|
]
|
|
|
|
import { BACKEND_URL } from '../config'
|
|
|
|
export default function Firmware() {
|
|
const [firmwareData, setFirmwareData] = useState(null)
|
|
const [installerData, setInstallerData] = useState(null)
|
|
const [agents, setAgents] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [installerUploading, setInstallerUploading] = useState(false)
|
|
const [version, setVersion] = useState('')
|
|
const [platform, setPlatform] = useState('freebsd')
|
|
const [installerPlatform, setInstallerPlatform] = useState('freebsd')
|
|
const [msg, setMsg] = useState('')
|
|
const [msgType, setMsgType] = useState('info')
|
|
const fileRef = useRef(null)
|
|
const installerFileRef = useRef(null)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const load = async () => {
|
|
try {
|
|
const [fw, ag, inst] = await Promise.all([
|
|
api.getFirmwareInfo().catch(() => null),
|
|
api.getAgents().catch(() => []),
|
|
api.getInstallerInfo().catch(() => null),
|
|
])
|
|
setFirmwareData(fw)
|
|
setAgents(Array.isArray(ag) ? ag : [])
|
|
setInstallerData(inst)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
const showMsg = (text, type = 'info') => {
|
|
setMsg(text)
|
|
setMsgType(type)
|
|
setTimeout(() => setMsg(''), 5000)
|
|
}
|
|
|
|
const getFwForPlatform = (p) => {
|
|
if (!firmwareData?.platforms) return null
|
|
return firmwareData.platforms.find(f => f.platform === p)
|
|
}
|
|
|
|
const handleUpload = async () => {
|
|
const file = fileRef.current?.files?.[0]
|
|
if (!file) return showMsg('Bitte Binary auswaehlen', 'error')
|
|
if (!version.trim()) return showMsg('Bitte Version angeben', 'error')
|
|
|
|
setUploading(true)
|
|
try {
|
|
const result = await api.uploadFirmware(version.trim(), platform, file)
|
|
showMsg(`Firmware v${result.version} (${result.platform}) hochgeladen (${formatSize(result.size)})`, 'success')
|
|
setVersion('')
|
|
fileRef.current.value = ''
|
|
load()
|
|
} catch (err) {
|
|
showMsg(`Upload fehlgeschlagen: ${err.message}`, 'error')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const requestUpdate = async (agentId, name) => {
|
|
try {
|
|
await api.requestAgentUpdate(agentId)
|
|
showMsg(`Update fuer ${name} angefordert`, 'success')
|
|
load()
|
|
} catch (err) {
|
|
showMsg(`Fehler: ${err.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
const cancelUpdate = async (agentId, name) => {
|
|
try {
|
|
await api.cancelAgentUpdate(agentId)
|
|
showMsg(`Update-Anforderung fuer ${name} geloescht`, 'info')
|
|
load()
|
|
} catch (err) {
|
|
showMsg(`Fehler: ${err.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
const requestAll = async () => {
|
|
try {
|
|
const result = await api.requestUpdateAll()
|
|
showMsg(result.message || 'Update fuer alle angefordert', 'success')
|
|
load()
|
|
} catch (err) {
|
|
showMsg(`Fehler: ${err.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
const formatSize = (bytes) => {
|
|
if (!bytes) return '--'
|
|
if (bytes > 1048576) return `${(bytes / 1048576).toFixed(1)} MB`
|
|
return `${(bytes / 1024).toFixed(0)} KB`
|
|
}
|
|
|
|
if (loading) return <div className="text-gray-500">Laden...</div>
|
|
|
|
const getFwForAgent = (agent) => {
|
|
const p = agent.platform || 'freebsd'
|
|
return getFwForPlatform(p)
|
|
}
|
|
|
|
const needsUpdate = (agent) => {
|
|
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>
|
|
|
|
{/* Status Message */}
|
|
{msg && (
|
|
<div className={`rounded-lg px-4 py-3 text-sm flex items-center gap-2 ${
|
|
msgType === 'error' ? 'bg-red-500/10 border border-red-500/30 text-red-400' :
|
|
msgType === 'success' ? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-400' :
|
|
'bg-blue-500/10 border border-blue-500/30 text-blue-400'
|
|
}`}>
|
|
{msgType === 'success' ? <CheckCircle className="w-4 h-4 shrink-0" /> :
|
|
msgType === 'error' ? <AlertTriangle className="w-4 h-4 shrink-0" /> : null}
|
|
{msg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Platform Firmware Overview */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Verfuegbare Firmware</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
{PLATFORMS.map(p => {
|
|
const fw = getFwForPlatform(p.id)
|
|
const Icon = p.icon
|
|
return (
|
|
<div key={p.id} className={`rounded-lg border p-4 ${
|
|
fw ? 'bg-gray-900/50 border-gray-700/50' : 'bg-gray-900/20 border-gray-800/50'
|
|
}`}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Icon className={`w-4 h-4 ${fw ? 'text-orange-400' : 'text-gray-600'}`} />
|
|
<span className={`text-sm font-medium ${fw ? 'text-white' : 'text-gray-600'}`}>{p.label}</span>
|
|
</div>
|
|
{fw ? (
|
|
<div className="space-y-1">
|
|
<div className="text-lg font-bold text-orange-400">v{fw.version}</div>
|
|
<div className="text-xs text-gray-500">{formatSize(fw.size)}</div>
|
|
<div className="text-[10px] text-gray-600 font-mono">{fw.hash?.substring(0, 16)}...</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-gray-600 mt-1">Nicht hochgeladen</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Upload */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Neue Firmware hochladen</h2>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap gap-3">
|
|
<select
|
|
value={platform}
|
|
onChange={(e) => setPlatform(e.target.value)}
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:border-orange-500 focus:outline-none"
|
|
>
|
|
{PLATFORMS.map(p => (
|
|
<option key={p.id} value={p.id}>{p.label}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Version (z.B. 1.0.2)"
|
|
value={version}
|
|
onChange={(e) => setVersion(e.target.value)}
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-orange-500 focus:outline-none w-40"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="file"
|
|
ref={fileRef}
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-400 file:mr-3 file:bg-gray-700 file:text-gray-300 file:border-0 file:rounded file:px-3 file:py-1 file:text-xs file:cursor-pointer flex-1"
|
|
/>
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={uploading}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors shrink-0"
|
|
>
|
|
{uploading ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
{uploading ? 'Hochladen...' : 'Hochladen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Installer Packages */}
|
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Installer-Pakete</h2>
|
|
<p className="text-xs text-gray-500 mb-4">
|
|
ZIP-Pakete mit install.sh und Plugin-Dateien. Werden auf neuen Geraeten per fetch heruntergeladen und ausgefuehrt.
|
|
</p>
|
|
|
|
{/* Existing installers */}
|
|
{installerData?.installers?.length > 0 && (
|
|
<div className="space-y-2 mb-4">
|
|
{installerData.installers.map(inst => (
|
|
<div key={inst.platform} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Package className="w-4 h-4 text-orange-400" />
|
|
<span className="text-white text-sm font-medium">{inst.platform === 'linux' ? 'Linux' : 'OPNsense / FreeBSD'}</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{inst.filename} — {formatSize(inst.size)} — {new Date(inst.uploaded_at).toLocaleString('de-DE')}
|
|
</div>
|
|
<div className="text-[10px] text-gray-600 font-mono mt-0.5">{inst.sha256?.substring(0, 32)}...</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Install command */}
|
|
{installerData?.installers?.some(i => i.platform === 'freebsd') && (
|
|
<div className="mb-4 bg-gray-950 rounded p-3 relative">
|
|
<div className="text-[10px] text-gray-500 mb-1">Installationsbefehl (OPNsense):</div>
|
|
<pre className="text-xs font-mono text-orange-300 whitespace-pre-wrap">{`fetch --no-verify-peer --no-verify-hostname -o /tmp/installer.zip "${BACKEND_URL}/api/v1/installer/download?platform=freebsd&api_key=YOUR_API_KEY" && cd /tmp && unzip -o installer.zip && sh /tmp/install.sh`}</pre>
|
|
<button
|
|
onClick={() => {
|
|
copyToClipboard(`fetch --no-verify-peer --no-verify-hostname -o /tmp/installer.zip "${BACKEND_URL}/api/v1/installer/download?platform=freebsd&api_key=YOUR_API_KEY" && cd /tmp && unzip -o installer.zip && sh /tmp/install.sh`)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}}
|
|
className="absolute top-2 right-2 text-gray-600 hover:text-orange-400 transition-colors"
|
|
>
|
|
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload */}
|
|
<div className="flex flex-wrap gap-3 items-end">
|
|
<select
|
|
value={installerPlatform}
|
|
onChange={(e) => setInstallerPlatform(e.target.value)}
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:border-orange-500 focus:outline-none"
|
|
>
|
|
<option value="freebsd">OPNsense / FreeBSD</option>
|
|
<option value="linux">Linux</option>
|
|
</select>
|
|
<input
|
|
type="file"
|
|
ref={installerFileRef}
|
|
accept=".zip,.sh"
|
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-400 file:mr-3 file:bg-gray-700 file:text-gray-300 file:border-0 file:rounded file:px-3 file:py-1 file:text-xs file:cursor-pointer flex-1"
|
|
/>
|
|
<button
|
|
onClick={async () => {
|
|
const file = installerFileRef.current?.files?.[0]
|
|
if (!file) return showMsg('Bitte Datei auswaehlen', 'error')
|
|
setInstallerUploading(true)
|
|
try {
|
|
const result = await api.uploadInstaller(installerPlatform, file)
|
|
showMsg(`Installer fuer ${installerPlatform} hochgeladen (${formatSize(result.size)})`, 'success')
|
|
installerFileRef.current.value = ''
|
|
load()
|
|
} catch (err) {
|
|
showMsg(`Upload fehlgeschlagen: ${err.message}`, 'error')
|
|
} finally {
|
|
setInstallerUploading(false)
|
|
}
|
|
}}
|
|
disabled={installerUploading}
|
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors shrink-0"
|
|
>
|
|
{installerUploading ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
{installerUploading ? 'Hochladen...' : 'Hochladen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent List */}
|
|
<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>
|
|
{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"
|
|
>
|
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
|
Alle updaten
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{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">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${
|
|
agent.status === 'online' ? 'bg-emerald-500' :
|
|
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 ${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 && 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0 ml-3">
|
|
{pending ? (
|
|
<button
|
|
onClick={() => cancelUpdate(agent.id, agent.name)}
|
|
className="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1.5 rounded text-xs transition-colors"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
Abbrechen
|
|
</button>
|
|
) : outdated ? (
|
|
<button
|
|
onClick={() => requestUpdate(agent.id, agent.name)}
|
|
className="flex items-center gap-1.5 bg-orange-600 hover:bg-orange-500 text-white px-3 py-1.5 rounded text-xs transition-colors"
|
|
>
|
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
|
Update
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|