feat: Updates-Tab, Aufgaben-Tab ProxmoxPanel, security_only Tasks, 3x Netzwerk-Charts, Updates-Spalte Übersicht, dist-upgrade, DeleteAgent cascades metrics

This commit is contained in:
cynfo3000 2026-03-07 14:30:12 +01:00
parent a51d1bb02f
commit c76868e806
13 changed files with 925 additions and 139 deletions

View File

@ -1,4 +1,4 @@
.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend
.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend
VERSION ?= 0.0.0
@ -72,6 +72,13 @@ certs:
-addext "subjectAltName=IP:192.168.85.13,IP:127.0.0.1,DNS:localhost"
@echo "==> Zertifikate in certs/ erstellt"
deploy-frontend:
cd frontend && npm run build
ssh root@192.168.85.20 "rm -rf /var/www/html/assets/*"
scp -r frontend/dist/assets frontend/dist/index.html root@192.168.85.20:/var/www/html/
ssh root@192.168.85.20 "chown -R www-data:www-data /var/www/html/ && chmod -R 755 /var/www/html/"
@echo "==> Frontend deployed"
deploy-backend: backend
@echo "==> Deploying Backend to $(BACKEND_HOST)..."
ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true"

View File

@ -8,27 +8,32 @@ import (
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
SecurityCount int `json:"security_count"`
Updates []PendingUpdateInfo `json:"updates"`
}
type PendingUpdateInfo struct {
Package string `json:"package"`
Arch string `json:"arch"`
CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"`
Repository string `json:"repository"`
Security bool `json:"security"`
}
func CollectUpdates() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{},
}
// apt list --upgradable 2>/dev/null
// apt list --upgradable, LC_ALL=C erzwingt englische Ausgabe
cmd := exec.Command("/usr/bin/apt", "list", "--upgradable")
cmd.Env = append(cmd.Env, "LC_ALL=C", "LANG=C", "PATH=/usr/bin:/bin")
output, err := cmd.CombinedOutput()
if err != nil {
// Fallback: apt-check falls verfügbar
return collectUpdatesAptCheck()
}
@ -39,43 +44,70 @@ func CollectUpdates() *UpdateInfo {
continue
}
// Format: package/suite version [upgradable from: old-version]
// Beispiel: curl/focal-updates 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6]
// Format: package/suite new-version arch [upgradable from: old-version]
// Beispiel: curl/focal-security 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6]
if !strings.Contains(line, "[upgradable from:") {
continue
}
// Package-Name extrahieren (bis zum ersten /)
// Package-Name und Suite trennen
parts := strings.SplitN(line, "/", 2)
if len(parts) < 2 {
continue
}
packageName := parts[0]
// Neue Version extrahieren (zwischen / und [upgradable from:)
remaining := parts[1]
beforeUpgradable := strings.Split(remaining, " [upgradable from:")
// Suite ist das erste Wort vor dem Leerzeichen
suiteParts := strings.SplitN(remaining, " ", 2)
suite := suiteParts[0]
// Repository: letzter Teil der Suite (z.B. "focal-security/main" → "main")
// oder einfach die Suite selbst
repository := suite
if idx := strings.LastIndex(suite, "/"); idx >= 0 {
repository = suite[idx+1:]
}
// Security erkennen: Suite enthält "security"
// Beispiel: bookworm-security, trixie-security, buster/updates
suiteLower := strings.ToLower(suite)
isSecurity := strings.Contains(suiteLower, "security") ||
strings.HasSuffix(suiteLower, "/updates")
// Rest nach Suite parsen: "new-version arch [upgradable from: old-version]"
afterSuite := ""
if len(suiteParts) > 1 {
afterSuite = suiteParts[1]
}
beforeUpgradable := strings.SplitN(afterSuite, " [upgradable from:", 2)
if len(beforeUpgradable) < 2 {
continue
}
// Neue Version ist im ersten Teil, aber wir müssen die Architektur entfernen
versionParts := strings.Fields(beforeUpgradable[0])
if len(versionParts) < 1 {
continue
versionAndArch := strings.Fields(strings.TrimSpace(beforeUpgradable[0]))
newVersion := ""
arch := ""
if len(versionAndArch) >= 1 {
newVersion = versionAndArch[0]
}
if len(versionAndArch) >= 2 {
arch = versionAndArch[1]
}
newVersion := versionParts[0]
// Alte Version extrahieren (zwischen "from:" und "]")
oldVersionPart := beforeUpgradable[1]
oldVersion := strings.TrimSuffix(strings.TrimSpace(oldVersionPart), "]")
oldVersion := strings.TrimSuffix(strings.TrimSpace(beforeUpgradable[1]), "]")
updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName,
Arch: arch,
CurrentVer: oldVersion,
NewVer: newVersion,
Repository: repository,
Security: isSecurity,
})
if isSecurity {
updates.SecurityCount++
}
}
updates.PendingCount = len(updates.Updates)
@ -89,30 +121,30 @@ func collectUpdatesAptCheck() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{},
}
// /usr/lib/update-notifier/apt-check
cmd := exec.Command("/usr/lib/update-notifier/apt-check")
output, err := cmd.CombinedOutput()
if err != nil {
// Noch ein Fallback: apt-get -s upgrade
return collectUpdatesAptGetSimulate()
}
// apt-check gibt "updates;security-updates" auf stderr aus
result := strings.TrimSpace(string(output))
parts := strings.SplitN(result, ";", 2)
if len(parts) >= 1 && parts[0] != "0" {
// Packages verfügbar, aber keine Details ohne apt list
updates.UpdateAvailable = true
updates.PendingCount = 1 // Platzhalter
updates.PendingCount = 1
updates.Updates = []PendingUpdateInfo{
{
Package: "system-updates",
Arch: "",
CurrentVer: "various",
NewVer: "available",
Repository: "unknown",
Security: false,
},
}
}
@ -125,10 +157,10 @@ func collectUpdatesAptGetSimulate() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{},
}
// apt-get -s upgrade (simulate)
cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade")
output, err := cmd.CombinedOutput()
if err != nil {
@ -138,18 +170,16 @@ func collectUpdatesAptGetSimulate() *UpdateInfo {
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Suche nach "Inst package-name" Zeilen
if strings.HasPrefix(line, "Inst ") {
fields := strings.Fields(line)
if len(fields) >= 3 {
packageName := fields[1]
// Sehr simple Erkennung, bessere Parsing schwierig ohne apt list
updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName,
Package: fields[1],
Arch: "",
CurrentVer: "unknown",
NewVer: "available",
Repository: "unknown",
Security: false,
})
}
}

Binary file not shown.

View File

@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"strconv"
"strings"
"time"
)
@ -40,6 +41,10 @@ func (h *Handler) HandleCommand(msg Message) {
return
case "agent_update":
response = h.handleAgentUpdate(msg)
case "update_check":
response = h.handleUpdateCheck(msg)
case "update":
response = h.handleUpdate(msg)
case "pty_start":
response = h.handlePTYStart(msg)
case "pty_stop":
@ -321,3 +326,103 @@ func (h *Handler) runCommand(command string, timeoutSec int) (string, error) {
output, err := cmd.CombinedOutput()
return string(output), err
}
// handleUpdateCheck: apt update + Liste verfügbarer Pakete zurückgeben
func (h *Handler) handleUpdateCheck(msg Message) Message {
var response Message
response.Type = "response"
response.ID = msg.ID
log.Println("Update-Check gestartet (apt update)...")
// apt update (Paketlisten aktualisieren)
_, err := h.runCommand("DEBIAN_FRONTEND=noninteractive apt-get update -q 2>&1", 120)
if err != nil {
log.Printf("apt update Fehler: %v", err)
// Fehler ignorieren, apt update gibt oft non-zero bei Warnings zurück
}
// Verfügbare Updates abfragen
output, _ := h.runCommand("LC_ALL=C apt list --upgradable 2>/dev/null", 60)
// Security-Updates separat zählen
secOutput, _ := h.runCommand("LC_ALL=C apt list --upgradable 2>/dev/null | grep -c security || true", 30)
secCount := 0
fmt.Sscanf(strings.TrimSpace(secOutput), "%d", &secCount)
// Pakete zählen (Zeilen ohne "Listing..." und Leerzeilen)
lines := strings.Split(output, "\n")
pkgCount := 0
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" && !strings.HasPrefix(l, "Listing") && !strings.HasPrefix(l, "WARNING") {
pkgCount++
}
}
response.Status = "ok"
response.Data = map[string]interface{}{
"update_available": pkgCount > 0,
"pending_count": pkgCount,
"security_count": secCount,
"output": output,
}
return response
}
// handleUpdate: apt-get upgrade ausführen
func (h *Handler) handleUpdate(msg Message) Message {
var response Message
response.Type = "response"
response.ID = msg.ID
reboot := false
if r, ok := msg.Params["reboot"].(bool); ok {
reboot = r
}
securityOnly := false
if s, ok := msg.Params["security_only"].(bool); ok {
securityOnly = s
}
log.Printf("Update gestartet (reboot=%v, security_only=%v)", reboot, securityOnly)
// apt update
h.runCommand("DEBIAN_FRONTEND=noninteractive apt-get update -q 2>&1", 120)
// apt-get upgrade oder nur Security-Updates
var upgradeCmd string
if securityOnly {
upgradeCmd = `DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade \
$(LC_ALL=C apt list --upgradable 2>/dev/null | grep security | cut -d/ -f1 | tr '\n' ' ') 2>&1`
} else {
// dist-upgrade statt upgrade: löst auch neue Abhängigkeiten auf (nötig für Proxmox/PVE-Pakete)
upgradeCmd = "DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y 2>&1"
}
output, err := h.runCommand(upgradeCmd, 900)
if err != nil {
log.Printf("apt upgrade Fehler: %v", err)
response.Status = "error"
response.Error = fmt.Sprintf("Update fehlgeschlagen: %v", err)
response.Data = map[string]interface{}{"output": output}
return response
}
log.Println("Update abgeschlossen")
response.Status = "ok"
response.Data = map[string]interface{}{
"message": "Update erfolgreich",
"output": output,
"reboot": reboot,
}
if reboot {
log.Println("Reboot nach Update in 10 Sekunden...")
go func() {
time.Sleep(10 * time.Second)
exec.Command("/sbin/shutdown", "-r", "now").Run()
}()
}
return response
}

View File

@ -62,7 +62,7 @@ func (s *TaskScheduler) executeTask(task models.Task) {
case "backup":
result, taskErr = s.executeBackup(task.AgentID)
case "update":
result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout)
result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout, task.SecurityOnly)
default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action)
}
@ -174,10 +174,10 @@ func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) {
}
func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) {
return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600)
return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600, false)
}
func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int) (interface{}, error) {
func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int, securityOnly bool) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden")
}
@ -212,7 +212,7 @@ func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool
"type": "command",
"id": updateCmdID,
"command": "update",
"params": map[string]interface{}{"reboot": reboot},
"params": map[string]interface{}{"reboot": reboot, "security_only": securityOnly},
}
updateCh := s.hub.RegisterResponseWaiter(updateCmdID)

View File

@ -79,6 +79,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
Reboot bool `json:"reboot"`
HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"`
SecurityOnly bool `json:"security_only"`
WebhookURL string `json:"webhook_url"`
Active *bool `json:"active"`
}
@ -117,7 +118,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
req.AgentID, req.Action, req.ScheduledAt, req.WebhookURL, createdBy,
req.Name, req.ScheduleType, req.ScheduleTime,
req.ScheduleWeekday, req.ScheduleMonthday,
req.Reboot, req.HealthCheck, req.HealthCheckTimeout, active,
req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.SecurityOnly, active,
)
if err != nil {
log.Printf("Task erstellen fehlgeschlagen: %v", err)

View File

@ -261,6 +261,7 @@ func (d *Database) migrate() error {
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS reboot BOOLEAN DEFAULT FALSE`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS health_check BOOLEAN DEFAULT FALSE`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS health_check_timeout INT DEFAULT 600`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS security_only BOOLEAN DEFAULT FALSE`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT TRUE`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_run TIMESTAMPTZ`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_run TIMESTAMPTZ`)
@ -759,6 +760,9 @@ func (d *Database) GetAllInstallerInfo() ([]map[string]interface{}, error) {
}
func (d *Database) DeleteAgent(id string) (bool, error) {
// metrics hat keinen FK-Cascade → manuell löschen
d.db.Exec("DELETE FROM metrics WHERE agent_id = $1", id)
// agents löschen → CASCADE löscht system_data, tasks, config_backups, agent_events
res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id)
if err != nil {
return false, err

View File

@ -62,7 +62,7 @@ func CalculateNextRun(scheduleType, scheduleTime string, weekday, monthday *int,
func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string,
name, scheduleType, scheduleTime string, weekday, monthday *int,
reboot, healthCheck bool, healthCheckTimeout int, active bool) (*models.Task, error) {
reboot, healthCheck bool, healthCheckTimeout int, securityOnly bool, active bool) (*models.Task, error) {
validActions := map[string]bool{"backup": true, "update": true}
if !validActions[action] {
@ -107,12 +107,12 @@ func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdB
err := d.db.QueryRow(`
INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by,
schedule_type, schedule_time, schedule_weekday, schedule_monthday,
reboot, health_check, health_check_timeout, active, next_run)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
reboot, health_check, health_check_timeout, security_only, active, next_run)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id
`, agentID, name, action, schedTime, webhookURL, createdBy,
scheduleType, scheduleTime, weekday, monthday,
reboot, healthCheck, healthCheckTimeout, active, nextRun).Scan(&taskID)
reboot, healthCheck, healthCheckTimeout, securityOnly, active, nextRun).Scan(&taskID)
if err != nil {
return nil, fmt.Errorf("Task erstellen: %w", err)
}
@ -129,7 +129,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
var agentName, customerName, customerNumber sql.NullString
var name, scheduleType, scheduleTime sql.NullString
var scheduleWeekday, scheduleMonthday sql.NullInt32
var reboot, healthCheck, active sql.NullBool
var reboot, healthCheck, securityOnly, active sql.NullBool
var healthCheckTimeout, runCount sql.NullInt32
err := scan(
@ -138,7 +138,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
&resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
&createdBy, &createdAt,
&scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday,
&reboot, &healthCheck, &healthCheckTimeout, &active, &lastRun, &nextRun, &runCount,
&reboot, &healthCheck, &healthCheckTimeout, &securityOnly, &active, &lastRun, &nextRun, &runCount,
)
if err != nil {
return nil, err
@ -170,6 +170,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
if healthCheckTimeout.Valid {
t.HealthCheckTimeout = int(healthCheckTimeout.Int32)
}
t.SecurityOnly = securityOnly.Valid && securityOnly.Bool
t.Active = !active.Valid || active.Bool
t.RunCount = int(runCount.Int32)
@ -214,7 +215,7 @@ const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name,
t.result, t.webhook_url, t.webhook_sent, t.webhook_response,
t.created_by, t.created_at,
t.schedule_type, t.schedule_time, t.schedule_weekday, t.schedule_monthday,
t.reboot, t.health_check, t.health_check_timeout, t.active, t.last_run, t.next_run, t.run_count`
t.reboot, t.health_check, t.health_check_timeout, COALESCE(t.security_only, FALSE), t.active, t.last_run, t.next_run, t.run_count`
const taskFromJoin = `FROM tasks t
LEFT JOIN agents a ON a.id = t.agent_id

View File

@ -157,16 +157,27 @@ type PluginInfo struct {
}
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
SecurityCount int `json:"security_count,omitempty"`
Updates []PendingUpdateInfo `json:"updates"`
OPNsenseUpdate string `json:"opnsense_update,omitempty"`
OPNsenseUpdate string `json:"opnsense_update,omitempty"`
}
type PendingUpdateInfo struct {
Package string `json:"package"`
Arch string `json:"arch,omitempty"`
CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"`
Repository string `json:"repository,omitempty"`
Security bool `json:"security,omitempty"`
}
type UpdateInfoFull struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
SecurityCount int `json:"security_count,omitempty"`
Updates []PendingUpdateInfo `json:"updates"`
}
type CronJob struct {

View File

@ -20,6 +20,7 @@ type Task struct {
Reboot bool `json:"reboot"`
HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"`
SecurityOnly bool `json:"security_only,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"`
WebhookSent bool `json:"webhook_sent"`
WebhookResponse string `json:"webhook_response,omitempty"`

View File

@ -38,7 +38,7 @@ const subItems = {
],
system: [
{ id: 'services', label: 'Dienste', icon: Settings },
{ id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd'] },
{ id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd', 'linux'] },
{ id: 'agent', label: 'Agent', icon: Cpu },
],
}
@ -117,9 +117,9 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
if (tab === 'dhcp') return <DHCPTab sys={sys} />
if (tab === 'gateways') return <GatewaysTab sys={sys} />
if (tab === 'certs') return <CertsTab sys={sys} />
if (tab === 'updates') return <UpdatesTab agentId={agentId} />
if (tab === 'updates') return platform === 'linux' ? <LinuxUpdatesTab sys={sys} agentId={agentId} /> : <UpdatesTab agentId={agentId} />
if (tab === 'security') return <SecurityTab sys={sys} />
if (tab === 'tasks') return <TasksTab agentId={agentId} agentName={agent?.name || ''} />
if (tab === 'tasks') return <TasksTab agentId={agentId} agentName={agent?.name || ''} platform={platform} />
if (tab === 'backups') return platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
if (tab === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
if (tab === 'haproxy') return <HAProxyTab sys={sys} />
@ -566,57 +566,129 @@ function StatusCard({ icon: Icon, value, label }) {
)
}
function IfaceChart({ agentId, ifaceName }) {
const IFACE_RANGES = [
{ label: '8h', hours: 8, limit: 200 },
{ label: '24h', hours: 24, limit: 400 },
{ label: '7d', hours: 24 * 7, limit: 500 },
]
function IfaceChartSingle({ agentId, ifaceName, range }) {
const [rxData, setRxData] = useState([])
const [txData, setTxData] = useState([])
useEffect(() => {
if (!agentId || !ifaceName) return
const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString()
const params = `&from=${from}&limit=200&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}`
setRxData([])
setTxData([])
const from = new Date(Date.now() - range.hours * 60 * 60 * 1000).toISOString()
const params = `&from=${from}&limit=${range.limit}&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}`
api.getMetrics(agentId, 'interface_rx_bps', params).then(d => { if (Array.isArray(d)) setRxData(d) }).catch(() => {})
api.getMetrics(agentId, 'interface_tx_bps', params).then(d => { if (Array.isArray(d)) setTxData(d) }).catch(() => {})
}, [agentId, ifaceName])
if (rxData.length < 2 && txData.length < 2) {
return <div className="text-xs text-gray-600 py-2 text-center">Noch keine Verlaufsdaten (ab dem nächsten Heartbeat)</div>
}
// Gemeinsame SVG mit zwei Linien (RX blau, TX grün)
const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)]
const maxVal = Math.max(...allVals, 1)
const w = 100, h = 50
const toPath = (data) => data.map((d, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (d.value / maxVal) * (h - 4) - 2
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}, [agentId, ifaceName, range])
const formatBps = (v) => {
if (v >= 1024 * 1024 * 1024) return `${(v / 1024 / 1024 / 1024).toFixed(1)} GB/s`
if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`
return `${v.toFixed(0)} B/s`
return `${Math.round(v)} B/s`
}
if (rxData.length < 2 && txData.length < 2) {
return (
<div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1">{range.label}</span>
<div className="text-xs text-gray-600 py-3 text-center bg-gray-800/30 rounded">Keine Daten</div>
</div>
)
}
const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)]
const maxVal = Math.max(...allVals, 1)
const magnitude = Math.pow(10, Math.floor(Math.log10(maxVal)))
const niceMax = Math.ceil(maxVal / magnitude) * magnitude
const PAD_LEFT = 56
const PAD_RIGHT = 8
const PAD_TOP = 8
const PAD_BOTTOM = 22
const SVG_W = 600
const SVG_H = 130
const chartW = SVG_W - PAD_LEFT - PAD_RIGHT
const chartH = SVG_H - PAD_TOP - PAD_BOTTOM
const toX = (i, len) => PAD_LEFT + (i / Math.max(len - 1, 1)) * chartW
const toY = (val) => PAD_TOP + chartH - (val / niceMax) * chartH
const toPath = (data) => data.map((d, i) => `${toX(i, data.length).toFixed(1)},${toY(d.value).toFixed(1)}`).join(' ')
const yTicks = [0, 0.5, 1.0].map(f => ({ val: niceMax * f, y: toY(niceMax * f) }))
const fmtLabel = (iso) => {
if (!iso) return ''
const d = new Date(iso)
if (range.hours > 24) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
const allData = rxData.length >= txData.length ? rxData : txData
const xTicks = allData.length >= 2
? [
{ label: fmtLabel(allData[0]?.time), x: toX(0, allData.length) },
{ label: fmtLabel(allData[Math.floor(allData.length / 2)]?.time), x: toX(Math.floor(allData.length / 2), allData.length) },
{ label: fmtLabel(allData.at(-1)?.time), x: toX(allData.length - 1, allData.length) },
]
: []
const lastRx = rxData.at(-1)?.value || 0
const lastTx = txData.at(-1)?.value || 0
return (
<div className="mt-3 pt-3 border-t border-gray-700/50">
<div className="flex flex-col">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500">Durchsatz letzte 8h</span>
<span className="text-xs text-gray-500 font-medium">{range.label}</span>
<div className="flex gap-3 text-xs">
<span className="text-blue-400"> {formatBps(lastRx)}</span>
<span className="text-green-400"> {formatBps(lastTx)}</span>
</div>
</div>
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="w-full">
<svg width="100%" viewBox={`0 0 ${SVG_W} ${SVG_H}`} preserveAspectRatio="xMidYMid meet" className="w-full" style={{ height: '130px' }}>
{yTicks.map(({ val, y }) => (
<g key={val}>
<line x1={PAD_LEFT} y1={y.toFixed(1)} x2={SVG_W - PAD_RIGHT} y2={y.toFixed(1)} stroke="#374151" strokeWidth="0.5" strokeDasharray={val === 0 ? 'none' : '3,3'} />
<text x={PAD_LEFT - 4} y={y + 4} textAnchor="end" fontSize="10" fill="#6b7280">{formatBps(val)}</text>
</g>
))}
{rxData.length >= 2 && <polygon points={[`${toX(0, rxData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`, ...rxData.map((d, i) => `${toX(i, rxData.length).toFixed(1)},${toY(d.value).toFixed(1)}`), `${toX(rxData.length - 1, rxData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`].join(' ')} fill="#3b82f620" />}
{txData.length >= 2 && <polygon points={[`${toX(0, txData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`, ...txData.map((d, i) => `${toX(i, txData.length).toFixed(1)},${toY(d.value).toFixed(1)}`), `${toX(txData.length - 1, txData.length).toFixed(1)},${(PAD_TOP + chartH).toFixed(1)}`].join(' ')} fill="#22c55e18" />}
{rxData.length >= 2 && <polyline points={toPath(rxData)} fill="none" stroke="#3b82f6" strokeWidth="1.5" strokeLinejoin="round" />}
{txData.length >= 2 && <polyline points={toPath(txData)} fill="none" stroke="#22c55e" strokeWidth="1.5" strokeLinejoin="round" />}
<line x1={PAD_LEFT} y1={PAD_TOP + chartH} x2={SVG_W - PAD_RIGHT} y2={PAD_TOP + chartH} stroke="#374151" strokeWidth="0.5" />
{xTicks.map(({ label, x }, idx) => (
<text key={idx} x={x} y={SVG_H - 4} textAnchor={idx === 0 ? 'start' : idx === xTicks.length - 1 ? 'end' : 'middle'} fontSize="10" fill="#6b7280">{label}</text>
))}
</svg>
</div>
)
}
function IfaceChart({ agentId, ifaceName }) {
return (
<div className="mt-3 pt-3 border-t border-gray-700/50 space-y-4">
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-blue-400 rounded" /><span className="text-blue-400">RX</span></span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-green-400 rounded" /><span className="text-green-400">TX</span></span>
</div>
{IFACE_RANGES.map(r => (
<IfaceChartSingle key={r.label} agentId={agentId} ifaceName={ifaceName} range={r} />
))}
</div>
)
}
function fmtTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function InterfacesTab({ sys, agentId }) {
const [expanded, setExpanded] = useState(null)
const ifaces = sys.network_interfaces || []
@ -1771,7 +1843,7 @@ function SecurityTab({ sys }) {
)
}
function TasksTab({ agentId, agentName }) {
function TasksTab({ agentId, agentName, platform }) {
const [tasks, setTasks] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
@ -1780,52 +1852,83 @@ function TasksTab({ agentId, agentName }) {
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [showWebhook, setShowWebhook] = useState(false)
const isLinux = platform === 'linux'
const defaultForm = {
name: '', action: 'backup', schedule_type: 'once', schedule_time: '02:00',
name: '', action: isLinux ? 'update' : 'backup', schedule_type: 'once', schedule_time: '02:00',
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
reboot: false, health_check: false, health_check_timeout: 600,
security_only: false,
webhook_url: '', active: true,
}
const jobTemplates = [
const jobTemplatesFreeBSD = [
{
label: 'Wöchentliches Update',
icon: '🔄',
form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly',
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
health_check_timeout: 600, active: true },
},
{
label: 'Monatliches Update',
icon: '📅',
form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
health_check_timeout: 600, active: true },
},
{
label: 'Tägliches Backup',
icon: '💾',
form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily',
schedule_time: '03:00', reboot: false, health_check: false,
health_check_timeout: 600, webhook_url: '', active: true },
schedule_time: '03:00', reboot: false, health_check: false, active: true },
},
{
label: 'Monatliches Backup',
icon: '🗄️',
form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly',
schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false,
health_check_timeout: 600, webhook_url: '', active: true },
schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, active: true },
},
{
label: 'Update + ERP-Webhook',
icon: '🔗',
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
health_check_timeout: 600, active: true },
showWebhook: true,
},
]
const jobTemplatesLinux = [
{
label: 'Wöchentlich alle Updates',
form: { name: 'Wöchentliches Debian-Update', action: 'update', schedule_type: 'weekly',
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true,
health_check_timeout: 600, security_only: false, active: true },
},
{
label: 'Monatlich alle Updates',
form: { name: 'Monatliches Debian-Update', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, security_only: false, active: true },
},
{
label: 'Wöchentlich nur Security',
form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly',
schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true,
health_check_timeout: 300, security_only: true, active: true },
},
{
label: 'Täglich Security (kein Reboot)',
form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily',
schedule_time: '04:00', reboot: false, health_check: false,
security_only: true, active: true },
},
{
label: 'Update + Webhook',
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, security_only: false, active: true },
showWebhook: true,
},
]
const jobTemplates = isLinux ? jobTemplatesLinux : jobTemplatesFreeBSD
const [form, setForm] = useState(defaultForm)
const loadTasks = () => {
@ -1850,6 +1953,7 @@ function TasksTab({ agentId, agentName }) {
if (form.action === 'update') {
data.reboot = form.reboot; data.health_check = form.health_check
if (form.health_check) data.health_check_timeout = parseInt(form.health_check_timeout) || 600
if (isLinux) data.security_only = form.security_only || false
}
if (form.webhook_url) data.webhook_url = form.webhook_url
await api.createTask(data)
@ -1997,10 +2101,10 @@ function TasksTab({ agentId, agentName }) {
<div>
<label className={labelCls}>Aktion *</label>
<div className="flex gap-2">
{['backup', 'update'].map(a => (
{(isLinux ? ['update'] : ['backup', 'update']).map(a => (
<button key={a} onClick={() => setForm({...form, action: a})}
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
{a === 'backup' ? 'Backup' : 'Update'}
{a === 'backup' ? 'Backup' : 'apt-get upgrade'}
</button>
))}
</div>
@ -2008,6 +2112,12 @@ function TasksTab({ agentId, agentName }) {
)}
{form.action === 'update' && (
<div className="space-y-2 pl-1">
{isLinux && (
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.security_only || false} onChange={e => setForm({...form, security_only: e.target.checked})} className="accent-orange-500" />
Nur Security-Updates (apt-get upgrade mit security-Filter)
</label>
)}
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
Reboot nach Update
@ -2214,6 +2324,166 @@ function BackupsTab({ agentId }) {
)
}
function LinuxUpdatesTab({ sys, agentId }) {
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [running, setRunning] = useState(false)
const [runResult, setRunResult] = useState(null)
const updates = sys?.updates?.updates || []
const secCount = updates.filter(u => u.security).length
const regCount = updates.length - secCount
const visible = updates.filter(u => {
if (filter === 'security' && !u.security) return false
if (filter === 'regular' && u.security) return false
if (search) {
const q = search.toLowerCase()
return u.package?.toLowerCase().includes(q) || u.repository?.toLowerCase().includes(q)
}
return true
})
const runUpdate = async () => {
if (!confirm('Alle Updates jetzt installieren?')) return
setRunning(true)
setRunResult(null)
try {
const res = await api.runUpdate(agentId, false)
setRunResult(res.data || res)
} catch (err) {
setRunResult({ error: err.message })
} finally {
setRunning(false)
}
}
if (updates.length === 0) {
return (
<div className="space-y-4">
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-6 text-center">
<div className="w-10 h-10 rounded-full bg-emerald-900/40 flex items-center justify-center mx-auto mb-3">
<Download className="w-5 h-5 text-emerald-400" />
</div>
<p className="text-emerald-400 font-medium text-sm">System ist aktuell</p>
<p className="text-gray-500 text-xs mt-1">Keine ausstehenden Updates</p>
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-white font-semibold">Verfügbare Updates</h3>
{secCount > 0 && (
<span className="flex items-center gap-1 bg-red-900/50 border border-red-700/50 text-red-300 text-xs font-semibold px-2.5 py-1 rounded">
<Shield className="w-3 h-3" /> {secCount} Security
</span>
)}
<span className={`text-xs font-semibold px-2.5 py-1 rounded border ${regCount > 0 ? 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300' : 'bg-gray-700/50 border-gray-600/50 text-gray-400'}`}>
{regCount} Regular
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={runUpdate}
disabled={running}
className="flex items-center gap-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-xs px-3 py-1.5 rounded transition-colors"
>
<Download className="w-3.5 h-3.5" />
{running ? 'Läuft...' : 'Updates installieren'}
</button>
</div>
</div>
{/* Filter + Suche */}
<div className="flex items-center gap-2 flex-wrap">
{[
{ id: 'all', label: `Alle (${updates.length})` },
{ id: 'security', label: `Security (${secCount})` },
{ id: 'regular', label: `Regular (${regCount})` },
].map(f => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`text-xs px-3 py-1.5 rounded transition-colors border ${
filter === f.id
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
}`}
>
{f.label}
</button>
))}
<div className="ml-auto flex items-center gap-2 bg-gray-800 border border-gray-700 rounded px-2 py-1">
<Search className="w-3.5 h-3.5 text-gray-500" />
<input
type="text"
placeholder="Suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-transparent text-xs text-gray-300 outline-none w-40 placeholder-gray-600"
/>
</div>
</div>
{/* Tabelle */}
<div className="bg-gray-900/60 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-700/70 text-gray-500 uppercase tracking-wide">
<th className="text-left px-4 py-2.5">Paket</th>
<th className="text-left px-4 py-2.5">Aktuelle Version</th>
<th className="text-left px-4 py-2.5">Neue Version</th>
<th className="text-left px-4 py-2.5">Repository</th>
</tr>
</thead>
<tbody>
{visible.map((pkg, i) => (
<tr
key={i}
className={`border-b border-gray-800/50 last:border-0 ${pkg.security ? 'bg-red-950/10' : ''}`}
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{pkg.security && <Shield className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
<span className="font-mono text-gray-200">{pkg.package}</span>
{pkg.security && (
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-red-900/60 border border-red-700/50 text-red-300">SEC</span>
)}
{pkg.arch && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/60 text-gray-400">{pkg.arch}</span>
)}
</div>
</td>
<td className="px-4 py-2 text-gray-500 font-mono">{pkg.current_version || '—'}</td>
<td className="px-4 py-2 font-mono">
<span className="text-gray-400"> </span>
<span className="text-emerald-400">{pkg.new_version}</span>
</td>
<td className="px-4 py-2 text-gray-500">{pkg.repository || '—'}</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-600">Keine Pakete gefunden</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Ergebnis */}
{runResult && (
<div className={`text-xs p-3 rounded border ${runResult.error ? 'bg-red-900/20 border-red-700/50 text-red-300' : 'bg-gray-800 border-gray-700 text-gray-300'}`}>
{runResult.error ? runResult.error : (runResult.output || runResult.message || JSON.stringify(runResult))}
</div>
)}
</div>
)
}
function UpdatesTab({ agentId }) {
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false)

View File

@ -7,7 +7,7 @@ import { copyToClipboard } from '../utils/clipboard'
import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug,
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play,
} from 'lucide-react'
const buildTabs = (hasPBS) => [
@ -20,6 +20,7 @@ const buildTabs = (hasPBS) => [
{ id: 'tunnel', label: 'Tunnel' },
{ id: 'services', label: 'Dienste' },
{ id: 'updates', label: 'Updates' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'backups', label: 'Backups' },
{ id: 'agent', label: 'Agent' },
]
@ -128,6 +129,8 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
<ServicesTab sys={sys} />
) : tab === 'updates' ? (
<UpdatesTab sys={sys} />
) : tab === 'tasks' ? (
<LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
) : tab === 'pbs' ? (
<PBSTab pbs={pbs} agentId={agentId} sys={sys} />
) : tab === 'backups' ? (
@ -235,6 +238,256 @@ function StatusCard({ icon: Icon, value, label }) {
)
}
function LinuxTasksTab({ agentId, agentName }) {
const [tasks, setTasks] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [showDialog, setShowDialog] = useState(false)
const [editTask, setEditTask] = useState(null)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const defaultForm = {
name: '', action: 'update', schedule_type: 'monthly', schedule_time: '02:00',
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
reboot: true, health_check: true, health_check_timeout: 600,
security_only: false, webhook_url: '', active: true,
}
const [form, setForm] = useState(defaultForm)
const templates = [
{ label: 'Wöchentlich alle Updates', form: { name: 'Wöchentliches Debian-Update', action: 'update', schedule_type: 'weekly', schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true, health_check_timeout: 600, security_only: false, active: true } },
{ label: 'Monatlich alle Updates', form: { name: 'Monatliches Debian-Update', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, security_only: false, active: true } },
{ label: 'Wöchentlich nur Security', form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly', schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true, health_check_timeout: 300, security_only: true, active: true } },
{ label: 'Täglich Security (kein Reboot)', form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily', schedule_time: '04:00', reboot: false, health_check: false, security_only: true, active: true } },
{ label: 'Update + Webhook', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, security_only: false, webhook_url: '', active: true } },
]
const loadTasks = () => {
api.getTasks({ agent_id: agentId, limit: 50 })
.then(r => { setTasks(r.data || []); setTotal(r.total || 0) })
.catch(() => setTasks([]))
.finally(() => setLoading(false))
}
useEffect(() => { loadTasks() }, [agentId])
useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId])
const handleCreate = async () => {
setCreating(true); setError('')
try {
const data = { agent_id: agentId, name: form.name, action: form.action,
schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active,
reboot: form.reboot, health_check: form.health_check,
health_check_timeout: parseInt(form.health_check_timeout) || 600,
security_only: form.security_only || false,
}
if (form.schedule_type === 'once' && form.scheduled_at) data.scheduled_at = new Date(form.scheduled_at).toISOString()
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
if (form.webhook_url) data.webhook_url = form.webhook_url
await api.createTask(data)
setShowDialog(false); setForm(defaultForm); loadTasks()
} catch (e) { setError(e.message) } finally { setCreating(false) }
}
const handleSave = async () => {
setCreating(true); setError('')
try {
const data = { name: form.name, schedule_type: form.schedule_type, schedule_time: form.schedule_time,
active: form.active, reboot: form.reboot, health_check: form.health_check,
health_check_timeout: parseInt(form.health_check_timeout) || 600,
security_only: form.security_only || false, webhook_url: form.webhook_url,
}
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
await api.updateTask(editTask.id, data)
setShowDialog(false); setEditTask(null); setForm(defaultForm); loadTasks()
} catch (e) { setError(e.message) } finally { setCreating(false) }
}
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
const fmtInterval = (t) => {
if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}`
if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}`
if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}`
return 'Einmalig'
}
const fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'
const inputCls = 'w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500'
const labelCls = 'block text-xs text-gray-400 mb-1'
const statusColor = (s) => {
if (s === 'completed') return 'text-emerald-400'
if (s === 'failed' || s === 'error') return 'text-red-400'
if (s === 'running') return 'text-blue-400'
if (s === 'completed_with_warning') return 'text-yellow-400'
return 'text-gray-400'
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">{total} Job(s)</div>
<button onClick={() => { setShowDialog(true); setEditTask(null); setForm(defaultForm) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors">
<Plus className="w-4 h-4" /> Neuer Job
</button>
</div>
{/* Modal */}
{showDialog && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<h3 className="text-white font-semibold">{editTask ? `Job bearbeiten` : `Neuer Job — ${agentName}`}</h3>
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{/* Vorlagen */}
{!editTask && (
<div className="px-5 pt-4 pb-3 border-b border-gray-700/50">
<p className="text-xs text-gray-500 mb-2">Vorlage wählen</p>
<div className="flex flex-wrap gap-2">
{templates.map((tpl, i) => (
<button key={i} onClick={() => setForm({ ...defaultForm, ...tpl.form })}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 hover:text-white transition-colors">
{tpl.label}
</button>
))}
</div>
</div>
)}
<div className="px-5 py-4 space-y-4">
<div>
<label className={labelCls}>Name *</label>
<input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Intervall *</label>
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls} disabled={!!editTask}>
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
<div>
<label className={labelCls}>Uhrzeit *</label>
<input type="time" value={form.schedule_time} onChange={e => setForm({...form, schedule_time: e.target.value})} className={inputCls} />
</div>
</div>
{form.schedule_type === 'weekly' && (
<div>
<label className={labelCls}>Wochentag</label>
<select value={form.schedule_weekday} onChange={e => setForm({...form, schedule_weekday: e.target.value})} className={inputCls}>
{weekdays.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
)}
{form.schedule_type === 'monthly' && (
<div>
<label className={labelCls}>Tag im Monat</label>
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
</div>
)}
{/* Update-Optionen */}
<div className="space-y-2 pl-1">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.security_only || false} onChange={e => setForm({...form, security_only: e.target.checked})} className="accent-orange-500" />
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5 text-red-400" /> Nur Security-Updates</span>
</label>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
Reboot nach Update
</label>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.health_check} onChange={e => setForm({...form, health_check: e.target.checked})} className="accent-orange-500" />
Health-Check nach Update
</label>
{form.health_check && (
<div className="pl-6">
<label className={labelCls}>Timeout (Sekunden)</label>
<input type="number" value={form.health_check_timeout} onChange={e => setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[160px]'} />
</div>
)}
</div>
<div>
<label className={labelCls}>Webhook-URL (optional)</label>
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.active} onChange={e => setForm({...form, active: e.target.checked})} className="accent-orange-500" />
Job ist aktiv
</label>
{error && <div className="text-sm text-red-400">{error}</div>}
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300">Abbrechen</button>
<button onClick={editTask ? handleSave : handleCreate} disabled={creating || !form.name}
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white">
{creating ? 'Speichere...' : editTask ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Tabelle */}
{loading ? <div className="text-gray-500 text-sm">Laden...</div> : tasks.length === 0 ? (
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">Keine Jobs vorhanden</div>
) : (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Zeitplan</th>
<th className="px-3 py-2">Optionen</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Letzter Lauf</th>
<th className="px-3 py-2">Nächster Lauf</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{tasks.map(t => (
<tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
<td className="px-3 py-2 text-gray-200 font-medium">{t.name}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
<td className="px-3 py-2">
<div className="flex gap-1 flex-wrap">
{t.security_only && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 border border-red-700/50 text-red-300 flex items-center gap-0.5"><Shield className="w-2.5 h-2.5" />SEC</span>}
{t.reboot && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Reboot</span>}
{t.health_check && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Health</span>}
</div>
</td>
<td className={`px-3 py-2 text-xs font-medium ${statusColor(t.status)}`}>{t.status || '—'}</td>
<td className="px-3 py-2 text-gray-500 text-xs">{fmtDate(t.last_run)}</td>
<td className="px-3 py-2 text-gray-500 text-xs">{fmtDate(t.next_run)}</td>
<td className="px-3 py-2">
<div className="flex gap-1">
<button onClick={() => api.runTaskNow(t.id).then(() => setTimeout(loadTasks, 1500))} title="Jetzt ausführen" className="p-1 text-gray-500 hover:text-orange-400"><Download className="w-3.5 h-3.5" /></button>
<button onClick={() => { setEditTask(t); setForm({ name: t.name, action: t.action, schedule_type: t.schedule_type || 'once', schedule_time: t.schedule_time || '02:00', scheduled_at: '', schedule_weekday: t.schedule_weekday ?? 1, schedule_monthday: t.schedule_monthday ?? 1, reboot: t.reboot || false, health_check: t.health_check || false, health_check_timeout: t.health_check_timeout || 600, security_only: t.security_only || false, webhook_url: t.webhook_url || '', active: t.active !== false }); setShowDialog(true) }} title="Bearbeiten" className="p-1 text-gray-500 hover:text-blue-400"><Settings className="w-3.5 h-3.5" /></button>
<button onClick={() => api.toggleTask(t.id).then(loadTasks)} title={t.active ? 'Deaktivieren' : 'Aktivieren'} className="p-1 text-gray-500 hover:text-yellow-400"><Monitor className="w-3.5 h-3.5" /></button>
<button onClick={() => confirm('Job löschen?') && api.deleteTask(t.id).then(loadTasks)} title="Löschen" className="p-1 text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
function OverviewTab({ sys, proxmox }) {
const rootDisk = sys.disks?.find((d) => d.mount_point === '/')
const diskPct = rootDisk && rootDisk.total_bytes > 0
@ -603,42 +856,122 @@ function ServicesTab({ sys }) {
}
function UpdatesTab({ sys }) {
const pendingUpdates = sys.pending_updates || []
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const updates = sys?.updates?.updates || sys?.pending_updates || []
const secCount = updates.filter(u => u.security).length
const regCount = updates.length - secCount
const visible = updates.filter(u => {
if (filter === 'security' && !u.security) return false
if (filter === 'regular' && u.security) return false
if (search) {
const q = search.toLowerCase()
return u.package?.toLowerCase().includes(q) || u.repository?.toLowerCase().includes(q)
}
return true
})
if (updates.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="w-10 h-10 rounded-full bg-emerald-900/40 flex items-center justify-center mb-3">
<Download className="w-5 h-5 text-emerald-400" />
</div>
<p className="text-emerald-400 font-medium text-sm">System ist aktuell</p>
<p className="text-gray-500 text-xs mt-1">Keine ausstehenden Updates</p>
</div>
)
}
return (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Updates</h3>
<div className="text-sm text-orange-400">
{pendingUpdates.length} Updates verfuegbar
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-white font-semibold">Verfügbare Updates</h3>
{secCount > 0 && (
<span className="flex items-center gap-1 bg-red-900/50 border border-red-700/50 text-red-300 text-xs font-semibold px-2.5 py-1 rounded">
<Shield className="w-3 h-3" /> {secCount} Security
</span>
)}
<span className={`text-xs font-semibold px-2.5 py-1 rounded border ${regCount > 0 ? 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300' : 'bg-gray-700/50 border-gray-600/50 text-gray-400'}`}>
{regCount} Regular
</span>
</div>
{/* Filter + Suche */}
<div className="flex items-center gap-2 flex-wrap">
{[
{ id: 'all', label: `Alle (${updates.length})` },
{ id: 'security', label: `Security (${secCount})` },
{ id: 'regular', label: `Regular (${regCount})` },
].map(f => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`text-xs px-3 py-1.5 rounded transition-colors border ${
filter === f.id
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
}`}
>
{f.label}
</button>
))}
<div className="ml-auto flex items-center gap-2 bg-gray-800 border border-gray-700 rounded px-2 py-1">
<Search className="w-3.5 h-3.5 text-gray-500" />
<input
type="text"
placeholder="Suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-transparent text-xs text-gray-300 outline-none w-36 placeholder-gray-600"
/>
</div>
</div>
{pendingUpdates.length === 0 ? (
<div className="text-gray-500">Keine Updates verfuegbar</div>
) : (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
<th className="px-3 py-2">Package</th>
<th className="px-3 py-2">Current Version</th>
<th className="px-3 py-2">New Version</th>
{/* Tabelle */}
<div className="bg-gray-900/60 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-700/70 text-gray-500 uppercase tracking-wide">
<th className="text-left px-4 py-2.5">Paket</th>
<th className="text-left px-4 py-2.5">Aktuelle Version</th>
<th className="text-left px-4 py-2.5">Neue Version</th>
<th className="text-left px-4 py-2.5">Repository</th>
</tr>
</thead>
<tbody>
{visible.map((pkg, i) => (
<tr key={i} className={`border-b border-gray-800/50 last:border-0 ${pkg.security ? 'bg-red-950/10' : ''}`}>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{pkg.security && <Shield className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
<span className="font-mono text-gray-200">{pkg.package}</span>
{pkg.security && (
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-red-900/60 border border-red-700/50 text-red-300">SEC</span>
)}
{pkg.arch && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/60 text-gray-400">{pkg.arch}</span>
)}
</div>
</td>
<td className="px-4 py-2 text-gray-500 font-mono">{pkg.current_version || '—'}</td>
<td className="px-4 py-2 font-mono">
<span className="text-gray-400"> </span>
<span className="text-emerald-400">{pkg.new_version}</span>
</td>
<td className="px-4 py-2 text-gray-500">{pkg.repository || '—'}</td>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{pendingUpdates.map((update, i) => (
<tr key={i}>
<td className="px-3 py-2 text-white font-medium">{update.package}</td>
<td className="px-3 py-2 text-gray-400">{update.current_version}</td>
<td className="px-3 py-2 text-orange-400">{update.new_version}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
))}
{visible.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-600">Keine Pakete gefunden</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useRef } from 'react'
import api from '../api/client'
import StatusBadge from '../components/StatusBadge'
import ProxmoxPanel from '../components/ProxmoxPanel'
import { Search, ChevronUp, ChevronDown, Plus, X, Settings2, BookOpen } from 'lucide-react'
import { Search, ChevronUp, ChevronDown, Plus, X, Settings2, BookOpen, Shield } from 'lucide-react'
import InstallGuide from '../components/InstallGuide'
const ALL_COLUMNS = [
@ -17,6 +17,7 @@ const ALL_COLUMNS = [
{ id: 'disk', label: 'Disk', default: true },
{ id: 'vms', label: 'VMs', default: true },
{ id: 'containers', label: 'Container', default: true },
{ id: 'updates', label: 'Updates', default: true },
{ id: 'lastresponse', label: 'Letzter Kontakt', default: false },
{ id: 'agentversion', label: 'Agent Version', default: false },
]
@ -152,6 +153,8 @@ export default function ProxmoxServers() {
ctRunning,
ctTotal,
hasPBS: sys?.pbs?.available === true,
updateCount: sys?.updates?.pending_count ?? null,
securityCount: sys?.updates?.security_count ?? 0,
sys,
proxmox,
}
@ -183,6 +186,7 @@ export default function ProxmoxServers() {
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 'updates': return sortDir === 'asc' ? (a.updateCount ?? -1) - (b.updateCount ?? -1) : (b.updateCount ?? -1) - (a.updateCount ?? -1)
case 'status': va = a.status; vb = b.status; break
default: va = a.name; vb = b.name
}
@ -288,6 +292,7 @@ export default function ProxmoxServers() {
{isColVisible('disk') && <SortHeader label="DISK" k="disk" />}
{isColVisible('vms') && <SortHeader label="VMS" k="vms" />}
{isColVisible('containers') && <SortHeader label="CT" k="containers" />}
{isColVisible('updates') && <SortHeader label="UPDATES" k="updates" />}
{isColVisible('lastresponse') && <th className="px-2 py-1.5 font-medium">LETZTER KONTAKT</th>}
{isColVisible('agentversion') && <th className="px-2 py-1.5 font-medium">AGENT</th>}
</tr>
@ -324,6 +329,24 @@ export default function ProxmoxServers() {
{isColVisible('disk') && <td className="px-2 py-1.5"><MiniBar value={a.diskPct} /></td>}
{isColVisible('vms') && <td className="px-2 py-1.5"><VmBadge running={a.vmsRunning} total={a.vmsTotal} /></td>}
{isColVisible('containers') && <td className="px-2 py-1.5"><VmBadge running={a.ctRunning} total={a.ctTotal} /></td>}
{isColVisible('updates') && (
<td className="px-2 py-1.5">
{a.updateCount === null ? (
<span className="text-gray-600 text-xs"></span>
) : a.updateCount === 0 ? (
<span className="text-emerald-500 text-xs font-medium">aktuell</span>
) : (
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-orange-400">{a.updateCount}</span>
{a.securityCount > 0 && (
<span className="flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded bg-red-900/50 border border-red-700/50 text-red-300">
<Shield className="w-2.5 h-2.5" />{a.securityCount}
</span>
)}
</div>
)}
</td>
)}
{isColVisible('lastresponse') && (
<td className="px-2 py-1.5 text-gray-500 text-xs">
{a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'}