Fix: SettingsController in install.sh aktualisiert (rmmagent[general] Parsing)
This commit is contained in:
@@ -97,6 +97,12 @@ class ApiClient {
|
||||
return this.del(`/api/v1/agents/${id}`)
|
||||
}
|
||||
|
||||
preRegisterAgent(name, customerId) {
|
||||
const body = { name }
|
||||
if (customerId) body.customer_id = customerId
|
||||
return this.post('/api/v1/agents', body)
|
||||
}
|
||||
|
||||
assignCustomer(agentId, customerId) {
|
||||
return this.put(`/api/v1/agents/${agentId}/customer`, { customer_id: customerId })
|
||||
}
|
||||
|
||||
@@ -60,9 +60,23 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
||||
{agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Firewall "${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="Firewall 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>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -250,6 +264,47 @@ function OverviewTab({ sys }) {
|
||||
<StatusCard value={`${svcRunning}/${svcTotal}`} label="Dienste" />
|
||||
</div>
|
||||
|
||||
{/* Lizenz */}
|
||||
{sys.license && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Lizenz</h3>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{sys.license.product_name || 'OPNsense'}</div>
|
||||
{sys.license.subscription && (
|
||||
<div className="text-xs text-gray-500 mt-1 font-mono">{sys.license.subscription}</div>
|
||||
)}
|
||||
</div>
|
||||
{sys.license.valid_to && (
|
||||
<div className="text-right">
|
||||
<div className={`text-lg font-bold ${
|
||||
sys.license.days_remaining < 30 ? 'text-red-400' :
|
||||
sys.license.days_remaining < 90 ? 'text-yellow-400' :
|
||||
'text-green-400'
|
||||
}`}>
|
||||
{sys.license.days_remaining > 0 ? `${sys.license.days_remaining} Tage` : 'Abgelaufen'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">gueltig bis {sys.license.valid_to}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sys.license.valid_to && sys.license.days_remaining != null && (
|
||||
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
sys.license.days_remaining < 30 ? 'bg-red-500' :
|
||||
sys.license.days_remaining < 90 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(2, sys.license.days_remaining / 365 * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Gateways */}
|
||||
{sys.gateways && sys.gateways.length > 0 && (
|
||||
<>
|
||||
@@ -1009,18 +1064,16 @@ function CertsTab({ sys }) {
|
||||
const remaining = (expires) => {
|
||||
if (!expires) return null
|
||||
const diff = expires - now
|
||||
if (diff < 0) return { text: 'Abgelaufen', color: 'text-red-400', bg: 'bg-red-500/20 border-red-500/30', barColor: 'bg-red-500', pct: 0 }
|
||||
if (diff < 0) return { text: 'Abgelaufen', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 }
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (days > 365) {
|
||||
const y = Math.floor(days / 365)
|
||||
const m = Math.floor((days % 365) / 30)
|
||||
return { text: `${y}J ${m}M`, color: 'text-emerald-400', bg: 'bg-emerald-500/20 border-emerald-500/30', barColor: 'bg-emerald-500', pct: 100 }
|
||||
return { text: `${y}J ${m}M`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: 100 }
|
||||
}
|
||||
if (days > 90) return { text: `${days} Tage`, color: 'text-emerald-400', bg: 'bg-emerald-500/20 border-emerald-500/30', barColor: 'bg-emerald-500', pct: Math.min(100, days / 3.65) }
|
||||
if (days > 30) return { text: `${days} Tage`, color: 'text-yellow-400', bg: 'bg-yellow-500/20 border-yellow-500/30', barColor: 'bg-yellow-500', pct: Math.min(100, days / 3.65) }
|
||||
if (days > 7) return { text: `${days} Tage`, color: 'text-orange-400', bg: 'bg-orange-500/20 border-orange-500/30', barColor: 'bg-orange-500', pct: Math.min(100, days / 3.65) }
|
||||
if (days > 0) return { text: `${days} Tage`, color: 'text-red-400', bg: 'bg-red-500/20 border-red-500/30', barColor: 'bg-red-500', pct: Math.min(100, days / 3.65) }
|
||||
return { text: 'Heute', color: 'text-red-400', bg: 'bg-red-500/20 border-red-500/30', barColor: 'bg-red-500', pct: 0 }
|
||||
if (days > 7) return { text: `${days} Tage`, color: 'text-gray-400', bg: 'bg-gray-800/50 border-gray-700/50', barColor: 'bg-emerald-500', pct: Math.min(100, days / 3.65) }
|
||||
if (days > 0) return { text: `${days} Tage`, color: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', barColor: 'bg-yellow-500', pct: Math.min(100, days / 3.65) }
|
||||
return { text: 'Heute', color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', barColor: 'bg-red-500', pct: 0 }
|
||||
}
|
||||
|
||||
const sorted = [...certs].sort((a, b) => {
|
||||
@@ -1033,7 +1086,7 @@ function CertsTab({ sys }) {
|
||||
const soonCount = sorted.filter(c => {
|
||||
if (!c.not_after) return false
|
||||
const e = new Date(c.not_after)
|
||||
return e >= now && (e - now) < 30 * 86400000
|
||||
return e >= now && (e - now) < 7 * 86400000
|
||||
}).length
|
||||
|
||||
return (
|
||||
@@ -1053,7 +1106,7 @@ function CertsTab({ sys }) {
|
||||
{soonCount > 0 && (
|
||||
<div className="bg-yellow-500/10 rounded-lg border border-yellow-500/30 px-4 py-2 flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-400">{soonCount}</div>
|
||||
<div className="text-xs text-yellow-400/70"><30 Tage</div>
|
||||
<div className="text-xs text-yellow-400/70"><7 Tage</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1065,32 +1118,22 @@ function CertsTab({ sys }) {
|
||||
const r = remaining(expires)
|
||||
const type = c.type || (c.is_ca ? 'CA' : 'Cert')
|
||||
return (
|
||||
<div key={i} className={`rounded-lg border p-3 ${r ? r.bg : 'bg-gray-800/50 border-gray-700/50'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-sm font-medium truncate">{c.name || c.subject || '—'}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/50 text-gray-400 shrink-0">{type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1 truncate">
|
||||
{c.issuer || 'Unbekannter Aussteller'}
|
||||
</div>
|
||||
<div key={i} className={`rounded border px-3 py-2 ${r ? r.bg : 'bg-gray-800/50 border-gray-700/50'}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="text-white text-xs font-medium truncate">{c.name || c.subject || '--'}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-700/50 text-gray-500 shrink-0">{type}</span>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{r ? (
|
||||
<>
|
||||
<div className={`text-sm font-semibold ${r.color}`}>{r.text}</div>
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{expires ? `bis ${expires.toLocaleDateString('de-DE')}` : ''}
|
||||
</div>
|
||||
</>
|
||||
<span className={`text-xs font-medium ${r.color}`}>{r.text}</span>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">Kein Ablauf</div>
|
||||
<span className="text-xs text-gray-600">--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{r && r.pct !== undefined && (
|
||||
<div className="mt-2 h-1 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<div className="mt-1 h-0.5 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${r.barColor}`} style={{ width: `${r.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import AgentPanel from '../components/AgentPanel'
|
||||
import { Search, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Search, ChevronUp, ChevronDown, Plus, Copy, Check, X } from 'lucide-react'
|
||||
|
||||
export default function Agents() {
|
||||
const [agents, setAgents] = useState([])
|
||||
@@ -12,6 +12,8 @@ export default function Agents() {
|
||||
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()])
|
||||
@@ -137,6 +139,12 @@ export default function Agents() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Firewalls ({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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -234,6 +242,161 @@ export default function Agents() {
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Firewall hinzufuegen Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddFirewallDialog
|
||||
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 AddFirewallDialog({ 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 = `# Vom Mac Studio aus ausfuehren:
|
||||
|
||||
# 1. Ins RMM-Verzeichnis wechseln:
|
||||
cd /Users/cynfo/.openclaw/workspace/rmm
|
||||
|
||||
# 2. Agent + Updater + Installer auf die Firewall kopieren:
|
||||
scp build/rmm-agent build/rmm-updater root@<FIREWALL-IP>:/tmp/
|
||||
scp opnsense-plugin/install.sh root@<FIREWALL-IP>:/tmp/
|
||||
|
||||
# 3. Installation starten:
|
||||
ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
||||
|
||||
# 4. Im OPNsense WebGUI unter Services > RMM Agent:
|
||||
# Backend URL: ${BACKEND_URL}
|
||||
# API Key: ${API_KEY}
|
||||
# Agent Name: ${name || '<NAME>'}
|
||||
# -> Save & Apply`
|
||||
|
||||
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">Firewall 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. OPN-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...' : 'Firewall 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)}...)
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user