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 VERSION ?= 0.0.0
@ -72,6 +72,13 @@ certs:
-addext "subjectAltName=IP:192.168.85.13,IP:127.0.0.1,DNS:localhost" -addext "subjectAltName=IP:192.168.85.13,IP:127.0.0.1,DNS:localhost"
@echo "==> Zertifikate in certs/ erstellt" @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 deploy-backend: backend
@echo "==> Deploying Backend to $(BACKEND_HOST)..." @echo "==> Deploying Backend to $(BACKEND_HOST)..."
ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true" ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true"

View File

@ -8,79 +8,111 @@ import (
type UpdateInfo struct { type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"` UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"` PendingCount int `json:"pending_count"`
SecurityCount int `json:"security_count"`
Updates []PendingUpdateInfo `json:"updates"` Updates []PendingUpdateInfo `json:"updates"`
} }
type PendingUpdateInfo struct { type PendingUpdateInfo struct {
Package string `json:"package"` Package string `json:"package"`
Arch string `json:"arch"`
CurrentVer string `json:"current_version"` CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"` NewVer string `json:"new_version"`
Repository string `json:"repository"`
Security bool `json:"security"`
} }
func CollectUpdates() *UpdateInfo { func CollectUpdates() *UpdateInfo {
updates := &UpdateInfo{ updates := &UpdateInfo{
UpdateAvailable: false, UpdateAvailable: false,
PendingCount: 0, PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{}, 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 := 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() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
// Fallback: apt-check falls verfügbar
return collectUpdatesAptCheck() return collectUpdatesAptCheck()
} }
lines := strings.Split(string(output), "\n") lines := strings.Split(string(output), "\n")
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "Listing...") { if line == "" || strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "Listing...") {
continue continue
} }
// Format: package/suite version [upgradable from: old-version] // Format: package/suite new-version arch [upgradable from: old-version]
// Beispiel: curl/focal-updates 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6] // Beispiel: curl/focal-security 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6]
if !strings.Contains(line, "[upgradable from:") { if !strings.Contains(line, "[upgradable from:") {
continue continue
} }
// Package-Name extrahieren (bis zum ersten /) // Package-Name und Suite trennen
parts := strings.SplitN(line, "/", 2) parts := strings.SplitN(line, "/", 2)
if len(parts) < 2 { if len(parts) < 2 {
continue continue
} }
packageName := parts[0] packageName := parts[0]
// Neue Version extrahieren (zwischen / und [upgradable from:)
remaining := parts[1] 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 { if len(beforeUpgradable) < 2 {
continue continue
} }
// Neue Version ist im ersten Teil, aber wir müssen die Architektur entfernen versionAndArch := strings.Fields(strings.TrimSpace(beforeUpgradable[0]))
versionParts := strings.Fields(beforeUpgradable[0]) newVersion := ""
if len(versionParts) < 1 { arch := ""
continue if len(versionAndArch) >= 1 {
newVersion = versionAndArch[0]
} }
newVersion := versionParts[0] if len(versionAndArch) >= 2 {
arch = versionAndArch[1]
// 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{ updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName, Package: packageName,
Arch: arch,
CurrentVer: oldVersion, CurrentVer: oldVersion,
NewVer: newVersion, NewVer: newVersion,
Repository: repository,
Security: isSecurity,
}) })
if isSecurity {
updates.SecurityCount++
}
} }
updates.PendingCount = len(updates.Updates) updates.PendingCount = len(updates.Updates)
updates.UpdateAvailable = updates.PendingCount > 0 updates.UpdateAvailable = updates.PendingCount > 0
return updates return updates
} }
@ -89,34 +121,34 @@ func collectUpdatesAptCheck() *UpdateInfo {
updates := &UpdateInfo{ updates := &UpdateInfo{
UpdateAvailable: false, UpdateAvailable: false,
PendingCount: 0, PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{}, Updates: []PendingUpdateInfo{},
} }
// /usr/lib/update-notifier/apt-check
cmd := exec.Command("/usr/lib/update-notifier/apt-check") cmd := exec.Command("/usr/lib/update-notifier/apt-check")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
// Noch ein Fallback: apt-get -s upgrade
return collectUpdatesAptGetSimulate() return collectUpdatesAptGetSimulate()
} }
// apt-check gibt "updates;security-updates" auf stderr aus
result := strings.TrimSpace(string(output)) result := strings.TrimSpace(string(output))
parts := strings.SplitN(result, ";", 2) parts := strings.SplitN(result, ";", 2)
if len(parts) >= 1 && parts[0] != "0" { if len(parts) >= 1 && parts[0] != "0" {
// Packages verfügbar, aber keine Details ohne apt list
updates.UpdateAvailable = true updates.UpdateAvailable = true
updates.PendingCount = 1 // Platzhalter updates.PendingCount = 1
updates.Updates = []PendingUpdateInfo{ updates.Updates = []PendingUpdateInfo{
{ {
Package: "system-updates", Package: "system-updates",
Arch: "",
CurrentVer: "various", CurrentVer: "various",
NewVer: "available", NewVer: "available",
Repository: "unknown",
Security: false,
}, },
} }
} }
return updates return updates
} }
@ -125,38 +157,36 @@ func collectUpdatesAptGetSimulate() *UpdateInfo {
updates := &UpdateInfo{ updates := &UpdateInfo{
UpdateAvailable: false, UpdateAvailable: false,
PendingCount: 0, PendingCount: 0,
SecurityCount: 0,
Updates: []PendingUpdateInfo{}, Updates: []PendingUpdateInfo{},
} }
// apt-get -s upgrade (simulate)
cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade") cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return updates return updates
} }
lines := strings.Split(string(output), "\n") lines := strings.Split(string(output), "\n")
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// Suche nach "Inst package-name" Zeilen
if strings.HasPrefix(line, "Inst ") { if strings.HasPrefix(line, "Inst ") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) >= 3 { if len(fields) >= 3 {
packageName := fields[1]
// Sehr simple Erkennung, bessere Parsing schwierig ohne apt list
updates.Updates = append(updates.Updates, PendingUpdateInfo{ updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName, Package: fields[1],
Arch: "",
CurrentVer: "unknown", CurrentVer: "unknown",
NewVer: "available", NewVer: "available",
Repository: "unknown",
Security: false,
}) })
} }
} }
} }
updates.PendingCount = len(updates.Updates) updates.PendingCount = len(updates.Updates)
updates.UpdateAvailable = updates.PendingCount > 0 updates.UpdateAvailable = updates.PendingCount > 0
return updates return updates
} }

Binary file not shown.

View File

@ -10,6 +10,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings"
"time" "time"
) )
@ -40,6 +41,10 @@ func (h *Handler) HandleCommand(msg Message) {
return return
case "agent_update": case "agent_update":
response = h.handleAgentUpdate(msg) response = h.handleAgentUpdate(msg)
case "update_check":
response = h.handleUpdateCheck(msg)
case "update":
response = h.handleUpdate(msg)
case "pty_start": case "pty_start":
response = h.handlePTYStart(msg) response = h.handlePTYStart(msg)
case "pty_stop": case "pty_stop":
@ -320,4 +325,104 @@ func (h *Handler) runCommand(command string, timeoutSec int) (string, error) {
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
return string(output), err 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": case "backup":
result, taskErr = s.executeBackup(task.AgentID) result, taskErr = s.executeBackup(task.AgentID)
case "update": 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: default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action) 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) { 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) { if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden") return nil, fmt.Errorf("Agent nicht verbunden")
} }
@ -212,7 +212,7 @@ func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool
"type": "command", "type": "command",
"id": updateCmdID, "id": updateCmdID,
"command": "update", "command": "update",
"params": map[string]interface{}{"reboot": reboot}, "params": map[string]interface{}{"reboot": reboot, "security_only": securityOnly},
} }
updateCh := s.hub.RegisterResponseWaiter(updateCmdID) updateCh := s.hub.RegisterResponseWaiter(updateCmdID)

View File

@ -79,6 +79,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
Reboot bool `json:"reboot"` Reboot bool `json:"reboot"`
HealthCheck bool `json:"health_check"` HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"` HealthCheckTimeout int `json:"health_check_timeout"`
SecurityOnly bool `json:"security_only"`
WebhookURL string `json:"webhook_url"` WebhookURL string `json:"webhook_url"`
Active *bool `json:"active"` 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.AgentID, req.Action, req.ScheduledAt, req.WebhookURL, createdBy,
req.Name, req.ScheduleType, req.ScheduleTime, req.Name, req.ScheduleType, req.ScheduleTime,
req.ScheduleWeekday, req.ScheduleMonthday, req.ScheduleWeekday, req.ScheduleMonthday,
req.Reboot, req.HealthCheck, req.HealthCheckTimeout, active, req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.SecurityOnly, active,
) )
if err != nil { if err != nil {
log.Printf("Task erstellen fehlgeschlagen: %v", err) 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 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 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 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 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 last_run TIMESTAMPTZ`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_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) { 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) res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id)
if err != nil { if err != nil {
return false, err 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, func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string,
name, scheduleType, scheduleTime string, weekday, monthday *int, 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} validActions := map[string]bool{"backup": true, "update": true}
if !validActions[action] { if !validActions[action] {
@ -107,12 +107,12 @@ func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdB
err := d.db.QueryRow(` err := d.db.QueryRow(`
INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by, INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by,
schedule_type, schedule_time, schedule_weekday, schedule_monthday, schedule_type, schedule_time, schedule_weekday, schedule_monthday,
reboot, health_check, health_check_timeout, active, next_run) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id RETURNING id
`, agentID, name, action, schedTime, webhookURL, createdBy, `, agentID, name, action, schedTime, webhookURL, createdBy,
scheduleType, scheduleTime, weekday, monthday, scheduleType, scheduleTime, weekday, monthday,
reboot, healthCheck, healthCheckTimeout, active, nextRun).Scan(&taskID) reboot, healthCheck, healthCheckTimeout, securityOnly, active, nextRun).Scan(&taskID)
if err != nil { if err != nil {
return nil, fmt.Errorf("Task erstellen: %w", err) 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 agentName, customerName, customerNumber sql.NullString
var name, scheduleType, scheduleTime sql.NullString var name, scheduleType, scheduleTime sql.NullString
var scheduleWeekday, scheduleMonthday sql.NullInt32 var scheduleWeekday, scheduleMonthday sql.NullInt32
var reboot, healthCheck, active sql.NullBool var reboot, healthCheck, securityOnly, active sql.NullBool
var healthCheckTimeout, runCount sql.NullInt32 var healthCheckTimeout, runCount sql.NullInt32
err := scan( err := scan(
@ -138,7 +138,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
&resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
&createdBy, &createdAt, &createdBy, &createdAt,
&scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday, &scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday,
&reboot, &healthCheck, &healthCheckTimeout, &active, &lastRun, &nextRun, &runCount, &reboot, &healthCheck, &healthCheckTimeout, &securityOnly, &active, &lastRun, &nextRun, &runCount,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -170,6 +170,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
if healthCheckTimeout.Valid { if healthCheckTimeout.Valid {
t.HealthCheckTimeout = int(healthCheckTimeout.Int32) t.HealthCheckTimeout = int(healthCheckTimeout.Int32)
} }
t.SecurityOnly = securityOnly.Valid && securityOnly.Bool
t.Active = !active.Valid || active.Bool t.Active = !active.Valid || active.Bool
t.RunCount = int(runCount.Int32) 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.result, t.webhook_url, t.webhook_sent, t.webhook_response,
t.created_by, t.created_at, t.created_by, t.created_at,
t.schedule_type, t.schedule_time, t.schedule_weekday, t.schedule_monthday, 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 const taskFromJoin = `FROM tasks t
LEFT JOIN agents a ON a.id = t.agent_id LEFT JOIN agents a ON a.id = t.agent_id

View File

@ -157,16 +157,27 @@ type PluginInfo struct {
} }
type UpdateInfo struct { type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"` UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"` PendingCount int `json:"pending_count"`
SecurityCount int `json:"security_count,omitempty"`
Updates []PendingUpdateInfo `json:"updates"` Updates []PendingUpdateInfo `json:"updates"`
OPNsenseUpdate string `json:"opnsense_update,omitempty"` OPNsenseUpdate string `json:"opnsense_update,omitempty"`
} }
type PendingUpdateInfo struct { type PendingUpdateInfo struct {
Package string `json:"package"` Package string `json:"package"`
Arch string `json:"arch,omitempty"`
CurrentVer string `json:"current_version"` CurrentVer string `json:"current_version"`
NewVer string `json:"new_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 { type CronJob struct {

View File

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

View File

@ -38,7 +38,7 @@ const subItems = {
], ],
system: [ system: [
{ id: 'services', label: 'Dienste', icon: Settings }, { 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 }, { 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 === 'dhcp') return <DHCPTab sys={sys} />
if (tab === 'gateways') return <GatewaysTab sys={sys} /> if (tab === 'gateways') return <GatewaysTab sys={sys} />
if (tab === 'certs') return <CertsTab 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 === '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 === '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 === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
if (tab === 'haproxy') return <HAProxyTab sys={sys} /> 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 [rxData, setRxData] = useState([])
const [txData, setTxData] = useState([]) const [txData, setTxData] = useState([])
useEffect(() => { useEffect(() => {
if (!agentId || !ifaceName) return if (!agentId || !ifaceName) return
const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString() setRxData([])
const params = `&from=${from}&limit=200&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}` 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_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(() => {}) api.getMetrics(agentId, 'interface_tx_bps', params).then(d => { if (Array.isArray(d)) setTxData(d) }).catch(() => {})
}, [agentId, ifaceName]) }, [agentId, ifaceName, range])
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(' ')
const formatBps = (v) => { 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 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/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 lastRx = rxData.at(-1)?.value || 0
const lastTx = txData.at(-1)?.value || 0 const lastTx = txData.at(-1)?.value || 0
return ( 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"> <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"> <div className="flex gap-3 text-xs">
<span className="text-blue-400"> {formatBps(lastRx)}</span> <span className="text-blue-400"> {formatBps(lastRx)}</span>
<span className="text-green-400"> {formatBps(lastTx)}</span> <span className="text-green-400"> {formatBps(lastTx)}</span>
</div> </div>
</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" />} {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" />} {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> </svg>
</div> </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 }) { function InterfacesTab({ sys, agentId }) {
const [expanded, setExpanded] = useState(null) const [expanded, setExpanded] = useState(null)
const ifaces = sys.network_interfaces || [] 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 [tasks, setTasks] = useState([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -1780,52 +1852,83 @@ function TasksTab({ agentId, agentName }) {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showWebhook, setShowWebhook] = useState(false) const [showWebhook, setShowWebhook] = useState(false)
const isLinux = platform === 'linux'
const defaultForm = { 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, scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
reboot: false, health_check: false, health_check_timeout: 600, reboot: false, health_check: false, health_check_timeout: 600,
security_only: false,
webhook_url: '', active: true, webhook_url: '', active: true,
} }
const jobTemplates = [ const jobTemplatesFreeBSD = [
{ {
label: 'Wöchentliches Update', label: 'Wöchentliches Update',
icon: '🔄',
form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly', form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly',
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true, 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', label: 'Monatliches Update',
icon: '📅',
form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly', form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, 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', label: 'Tägliches Backup',
icon: '💾',
form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily', form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily',
schedule_time: '03:00', reboot: false, health_check: false, schedule_time: '03:00', reboot: false, health_check: false, active: true },
health_check_timeout: 600, webhook_url: '', active: true },
}, },
{ {
label: 'Monatliches Backup', label: 'Monatliches Backup',
icon: '🗄️',
form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly', form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly',
schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, active: true },
health_check_timeout: 600, webhook_url: '', active: true },
}, },
{ {
label: 'Update + ERP-Webhook', label: 'Update + ERP-Webhook',
icon: '🔗',
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, 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, 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 [form, setForm] = useState(defaultForm)
const loadTasks = () => { const loadTasks = () => {
@ -1850,6 +1953,7 @@ function TasksTab({ agentId, agentName }) {
if (form.action === 'update') { if (form.action === 'update') {
data.reboot = form.reboot; data.health_check = form.health_check 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 (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 if (form.webhook_url) data.webhook_url = form.webhook_url
await api.createTask(data) await api.createTask(data)
@ -1997,10 +2101,10 @@ function TasksTab({ agentId, agentName }) {
<div> <div>
<label className={labelCls}>Aktion *</label> <label className={labelCls}>Aktion *</label>
<div className="flex gap-2"> <div className="flex gap-2">
{['backup', 'update'].map(a => ( {(isLinux ? ['update'] : ['backup', 'update']).map(a => (
<button key={a} onClick={() => setForm({...form, action: 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'}`}> 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> </button>
))} ))}
</div> </div>
@ -2008,6 +2112,12 @@ function TasksTab({ agentId, agentName }) {
)} )}
{form.action === 'update' && ( {form.action === 'update' && (
<div className="space-y-2 pl-1"> <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"> <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" /> <input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
Reboot nach Update 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 }) { function UpdatesTab({ agentId }) {
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)

View File

@ -7,7 +7,7 @@ import { copyToClipboard } from '../utils/clipboard'
import { import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check, 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' } from 'lucide-react'
const buildTabs = (hasPBS) => [ const buildTabs = (hasPBS) => [
@ -20,6 +20,7 @@ const buildTabs = (hasPBS) => [
{ id: 'tunnel', label: 'Tunnel' }, { id: 'tunnel', label: 'Tunnel' },
{ id: 'services', label: 'Dienste' }, { id: 'services', label: 'Dienste' },
{ id: 'updates', label: 'Updates' }, { id: 'updates', label: 'Updates' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'backups', label: 'Backups' }, { id: 'backups', label: 'Backups' },
{ id: 'agent', label: 'Agent' }, { id: 'agent', label: 'Agent' },
] ]
@ -128,6 +129,8 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
<ServicesTab sys={sys} /> <ServicesTab sys={sys} />
) : tab === 'updates' ? ( ) : tab === 'updates' ? (
<UpdatesTab sys={sys} /> <UpdatesTab sys={sys} />
) : tab === 'tasks' ? (
<LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
) : tab === 'pbs' ? ( ) : tab === 'pbs' ? (
<PBSTab pbs={pbs} agentId={agentId} sys={sys} /> <PBSTab pbs={pbs} agentId={agentId} sys={sys} />
) : tab === 'backups' ? ( ) : 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 }) { function OverviewTab({ sys, proxmox }) {
const rootDisk = sys.disks?.find((d) => d.mount_point === '/') const rootDisk = sys.disks?.find((d) => d.mount_point === '/')
const diskPct = rootDisk && rootDisk.total_bytes > 0 const diskPct = rootDisk && rootDisk.total_bytes > 0
@ -603,42 +856,122 @@ function ServicesTab({ sys }) {
} }
function UpdatesTab({ 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 ( return (
<> <div className="space-y-4">
<div className="flex items-center justify-between"> {/* Header */}
<h3 className="text-sm font-medium text-gray-300">Updates</h3> <div className="flex items-center gap-3 flex-wrap">
<div className="text-sm text-orange-400"> <h3 className="text-white font-semibold">Verfügbare Updates</h3>
{pendingUpdates.length} Updates verfuegbar {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>
</div> </div>
{pendingUpdates.length === 0 ? ( {/* Tabelle */}
<div className="text-gray-500">Keine Updates verfuegbar</div> <div className="bg-gray-900/60 rounded-lg border border-gray-700/50 overflow-hidden">
) : ( <table className="w-full text-xs">
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden"> <thead>
<table className="w-full text-sm"> <tr className="border-b border-gray-700/70 text-gray-500 uppercase tracking-wide">
<thead> <th className="text-left px-4 py-2.5">Paket</th>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700"> <th className="text-left px-4 py-2.5">Aktuelle Version</th>
<th className="px-3 py-2">Package</th> <th className="text-left px-4 py-2.5">Neue Version</th>
<th className="px-3 py-2">Current Version</th> <th className="text-left px-4 py-2.5">Repository</th>
<th className="px-3 py-2">New Version</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> </tr>
</thead> ))}
<tbody className="divide-y divide-gray-700/50"> {visible.length === 0 && (
{pendingUpdates.map((update, i) => ( <tr><td colSpan={4} className="px-4 py-6 text-center text-gray-600">Keine Pakete gefunden</td></tr>
<tr key={i}> )}
<td className="px-3 py-2 text-white font-medium">{update.package}</td> </tbody>
<td className="px-3 py-2 text-gray-400">{update.current_version}</td> </table>
<td className="px-3 py-2 text-orange-400">{update.new_version}</td> </div>
</tr> </div>
))}
</tbody>
</table>
</div>
)}
</>
) )
} }

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useRef } from 'react'
import api from '../api/client' import api from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import ProxmoxPanel from '../components/ProxmoxPanel' 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' import InstallGuide from '../components/InstallGuide'
const ALL_COLUMNS = [ const ALL_COLUMNS = [
@ -17,6 +17,7 @@ const ALL_COLUMNS = [
{ id: 'disk', label: 'Disk', default: true }, { id: 'disk', label: 'Disk', default: true },
{ id: 'vms', label: 'VMs', default: true }, { id: 'vms', label: 'VMs', default: true },
{ id: 'containers', label: 'Container', default: true }, { id: 'containers', label: 'Container', default: true },
{ id: 'updates', label: 'Updates', default: true },
{ id: 'lastresponse', label: 'Letzter Kontakt', default: false }, { id: 'lastresponse', label: 'Letzter Kontakt', default: false },
{ id: 'agentversion', label: 'Agent Version', default: false }, { id: 'agentversion', label: 'Agent Version', default: false },
] ]
@ -152,6 +153,8 @@ export default function ProxmoxServers() {
ctRunning, ctRunning,
ctTotal, ctTotal,
hasPBS: sys?.pbs?.available === true, hasPBS: sys?.pbs?.available === true,
updateCount: sys?.updates?.pending_count ?? null,
securityCount: sys?.updates?.security_count ?? 0,
sys, sys,
proxmox, 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 '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 '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 '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 case 'status': va = a.status; vb = b.status; break
default: va = a.name; vb = b.name default: va = a.name; vb = b.name
} }
@ -288,6 +292,7 @@ export default function ProxmoxServers() {
{isColVisible('disk') && <SortHeader label="DISK" k="disk" />} {isColVisible('disk') && <SortHeader label="DISK" k="disk" />}
{isColVisible('vms') && <SortHeader label="VMS" k="vms" />} {isColVisible('vms') && <SortHeader label="VMS" k="vms" />}
{isColVisible('containers') && <SortHeader label="CT" k="containers" />} {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('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>} {isColVisible('agentversion') && <th className="px-2 py-1.5 font-medium">AGENT</th>}
</tr> </tr>
@ -324,6 +329,24 @@ export default function ProxmoxServers() {
{isColVisible('disk') && <td className="px-2 py-1.5"><MiniBar value={a.diskPct} /></td>} {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('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('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') && ( {isColVisible('lastresponse') && (
<td className="px-2 py-1.5 text-gray-500 text-xs"> <td className="px-2 py-1.5 text-gray-500 text-xs">
{a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'} {a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'}