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:
parent
a51d1bb02f
commit
c76868e806
9
Makefile
9
Makefile
@ -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"
|
||||||
|
|||||||
@ -8,27 +8,32 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,43 +44,70 @@ func CollectUpdates() *UpdateInfo {
|
|||||||
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]
|
||||||
|
}
|
||||||
|
if len(versionAndArch) >= 2 {
|
||||||
|
arch = versionAndArch[1]
|
||||||
}
|
}
|
||||||
newVersion := versionParts[0]
|
|
||||||
|
|
||||||
// Alte Version extrahieren (zwischen "from:" und "]")
|
oldVersion := strings.TrimSuffix(strings.TrimSpace(beforeUpgradable[1]), "]")
|
||||||
oldVersionPart := beforeUpgradable[1]
|
|
||||||
oldVersion := strings.TrimSuffix(strings.TrimSpace(oldVersionPart), "]")
|
|
||||||
|
|
||||||
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)
|
||||||
@ -89,30 +121,30 @@ 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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,10 +157,10 @@ 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 {
|
||||||
@ -138,18 +170,16 @@ func collectUpdatesAptGetSimulate() *UpdateInfo {
|
|||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -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":
|
||||||
@ -321,3 +326,103 @@ func (h *Handler) runCommand(command string, timeoutSec int) (string, error) {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -159,14 +159,25 @@ 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 {
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
<tr className="border-b border-gray-700/70 text-gray-500 uppercase tracking-wide">
|
||||||
<th className="px-3 py-2">Package</th>
|
<th className="text-left px-4 py-2.5">Paket</th>
|
||||||
<th className="px-3 py-2">Current Version</th>
|
<th className="text-left px-4 py-2.5">Aktuelle Version</th>
|
||||||
<th className="px-3 py-2">New 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody>
|
||||||
{pendingUpdates.map((update, i) => (
|
{visible.map((pkg, i) => (
|
||||||
<tr key={i}>
|
<tr key={i} className={`border-b border-gray-800/50 last:border-0 ${pkg.security ? 'bg-red-950/10' : ''}`}>
|
||||||
<td className="px-3 py-2 text-white font-medium">{update.package}</td>
|
<td className="px-4 py-2">
|
||||||
<td className="px-3 py-2 text-gray-400">{update.current_version}</td>
|
<div className="flex items-center gap-2">
|
||||||
<td className="px-3 py-2 text-orange-400">{update.new_version}</td>
|
{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>
|
||||||
))}
|
))}
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-600">Keine Pakete gefunden</td></tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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') : '—'}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user