rmm2/frontend/src/pages/Firmware.jsx

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>
)
}