diff --git a/Makefile b/Makefile index 62b3cbb..3395db5 100644 --- a/Makefile +++ b/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 @@ -72,6 +72,13 @@ certs: -addext "subjectAltName=IP:192.168.85.13,IP:127.0.0.1,DNS:localhost" @echo "==> Zertifikate in certs/ erstellt" +deploy-frontend: + cd frontend && npm run build + ssh root@192.168.85.20 "rm -rf /var/www/html/assets/*" + scp -r frontend/dist/assets frontend/dist/index.html root@192.168.85.20:/var/www/html/ + ssh root@192.168.85.20 "chown -R www-data:www-data /var/www/html/ && chmod -R 755 /var/www/html/" + @echo "==> Frontend deployed" + deploy-backend: backend @echo "==> Deploying Backend to $(BACKEND_HOST)..." ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true" diff --git a/agent-linux/collector/updates.go b/agent-linux/collector/updates.go index 2b5ec23..327ee71 100644 --- a/agent-linux/collector/updates.go +++ b/agent-linux/collector/updates.go @@ -8,79 +8,111 @@ import ( type UpdateInfo struct { UpdateAvailable bool `json:"update_available"` PendingCount int `json:"pending_count"` + SecurityCount int `json:"security_count"` Updates []PendingUpdateInfo `json:"updates"` } type PendingUpdateInfo struct { Package string `json:"package"` + Arch string `json:"arch"` CurrentVer string `json:"current_version"` NewVer string `json:"new_version"` + Repository string `json:"repository"` + Security bool `json:"security"` } func CollectUpdates() *UpdateInfo { updates := &UpdateInfo{ UpdateAvailable: false, PendingCount: 0, + SecurityCount: 0, Updates: []PendingUpdateInfo{}, } - - // apt list --upgradable 2>/dev/null + + // apt list --upgradable, LC_ALL=C erzwingt englische Ausgabe cmd := exec.Command("/usr/bin/apt", "list", "--upgradable") + cmd.Env = append(cmd.Env, "LC_ALL=C", "LANG=C", "PATH=/usr/bin:/bin") output, err := cmd.CombinedOutput() if err != nil { - // Fallback: apt-check falls verfügbar return collectUpdatesAptCheck() } - + lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "Listing...") { continue } - - // Format: package/suite version [upgradable from: old-version] - // Beispiel: curl/focal-updates 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6] - + + // Format: package/suite new-version arch [upgradable from: old-version] + // Beispiel: curl/focal-security 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6] if !strings.Contains(line, "[upgradable from:") { continue } - - // Package-Name extrahieren (bis zum ersten /) + + // Package-Name und Suite trennen parts := strings.SplitN(line, "/", 2) if len(parts) < 2 { continue } packageName := parts[0] - - // Neue Version extrahieren (zwischen / und [upgradable from:) + remaining := parts[1] - beforeUpgradable := strings.Split(remaining, " [upgradable from:") + // Suite ist das erste Wort vor dem Leerzeichen + suiteParts := strings.SplitN(remaining, " ", 2) + suite := suiteParts[0] + // Repository: letzter Teil der Suite (z.B. "focal-security/main" → "main") + // oder einfach die Suite selbst + repository := suite + if idx := strings.LastIndex(suite, "/"); idx >= 0 { + repository = suite[idx+1:] + } + + // Security erkennen: Suite enthält "security" + // Beispiel: bookworm-security, trixie-security, buster/updates + suiteLower := strings.ToLower(suite) + isSecurity := strings.Contains(suiteLower, "security") || + strings.HasSuffix(suiteLower, "/updates") + + // Rest nach Suite parsen: "new-version arch [upgradable from: old-version]" + afterSuite := "" + if len(suiteParts) > 1 { + afterSuite = suiteParts[1] + } + + beforeUpgradable := strings.SplitN(afterSuite, " [upgradable from:", 2) if len(beforeUpgradable) < 2 { continue } - - // Neue Version ist im ersten Teil, aber wir müssen die Architektur entfernen - versionParts := strings.Fields(beforeUpgradable[0]) - if len(versionParts) < 1 { - continue + + versionAndArch := strings.Fields(strings.TrimSpace(beforeUpgradable[0])) + newVersion := "" + arch := "" + if len(versionAndArch) >= 1 { + newVersion = versionAndArch[0] } - newVersion := versionParts[0] - - // Alte Version extrahieren (zwischen "from:" und "]") - oldVersionPart := beforeUpgradable[1] - oldVersion := strings.TrimSuffix(strings.TrimSpace(oldVersionPart), "]") - + if len(versionAndArch) >= 2 { + arch = versionAndArch[1] + } + + oldVersion := strings.TrimSuffix(strings.TrimSpace(beforeUpgradable[1]), "]") + updates.Updates = append(updates.Updates, PendingUpdateInfo{ Package: packageName, + Arch: arch, CurrentVer: oldVersion, NewVer: newVersion, + Repository: repository, + Security: isSecurity, }) + if isSecurity { + updates.SecurityCount++ + } } - + updates.PendingCount = len(updates.Updates) updates.UpdateAvailable = updates.PendingCount > 0 - + return updates } @@ -89,34 +121,34 @@ func collectUpdatesAptCheck() *UpdateInfo { updates := &UpdateInfo{ UpdateAvailable: false, PendingCount: 0, + SecurityCount: 0, Updates: []PendingUpdateInfo{}, } - - // /usr/lib/update-notifier/apt-check + cmd := exec.Command("/usr/lib/update-notifier/apt-check") output, err := cmd.CombinedOutput() if err != nil { - // Noch ein Fallback: apt-get -s upgrade return collectUpdatesAptGetSimulate() } - - // apt-check gibt "updates;security-updates" auf stderr aus + result := strings.TrimSpace(string(output)) parts := strings.SplitN(result, ";", 2) - + if len(parts) >= 1 && parts[0] != "0" { - // Packages verfügbar, aber keine Details ohne apt list updates.UpdateAvailable = true - updates.PendingCount = 1 // Platzhalter + updates.PendingCount = 1 updates.Updates = []PendingUpdateInfo{ { Package: "system-updates", + Arch: "", CurrentVer: "various", NewVer: "available", + Repository: "unknown", + Security: false, }, } } - + return updates } @@ -125,38 +157,36 @@ func collectUpdatesAptGetSimulate() *UpdateInfo { updates := &UpdateInfo{ UpdateAvailable: false, PendingCount: 0, + SecurityCount: 0, Updates: []PendingUpdateInfo{}, } - - // apt-get -s upgrade (simulate) + cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade") output, err := cmd.CombinedOutput() if err != nil { return updates } - + lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) - - // Suche nach "Inst package-name" Zeilen if strings.HasPrefix(line, "Inst ") { fields := strings.Fields(line) if len(fields) >= 3 { - packageName := fields[1] - - // Sehr simple Erkennung, bessere Parsing schwierig ohne apt list updates.Updates = append(updates.Updates, PendingUpdateInfo{ - Package: packageName, + Package: fields[1], + Arch: "", CurrentVer: "unknown", NewVer: "available", + Repository: "unknown", + Security: false, }) } } } - + updates.PendingCount = len(updates.Updates) updates.UpdateAvailable = updates.PendingCount > 0 - + return updates -} \ No newline at end of file +} diff --git a/agent-linux/rmm-agent-linux b/agent-linux/rmm-agent-linux index 088ff65..68fc630 100755 Binary files a/agent-linux/rmm-agent-linux and b/agent-linux/rmm-agent-linux differ diff --git a/agent-linux/ws/handler.go b/agent-linux/ws/handler.go index e004984..e16ee47 100644 --- a/agent-linux/ws/handler.go +++ b/agent-linux/ws/handler.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "strconv" + "strings" "time" ) @@ -40,6 +41,10 @@ func (h *Handler) HandleCommand(msg Message) { return case "agent_update": response = h.handleAgentUpdate(msg) + case "update_check": + response = h.handleUpdateCheck(msg) + case "update": + response = h.handleUpdate(msg) case "pty_start": response = h.handlePTYStart(msg) case "pty_stop": @@ -320,4 +325,104 @@ func (h *Handler) runCommand(command string, timeoutSec int) (string, error) { cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) output, err := cmd.CombinedOutput() return string(output), err -} \ No newline at end of file +} +// 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 +} diff --git a/backend/api/scheduler.go b/backend/api/scheduler.go index 5efb521..ab15fe0 100644 --- a/backend/api/scheduler.go +++ b/backend/api/scheduler.go @@ -62,7 +62,7 @@ func (s *TaskScheduler) executeTask(task models.Task) { case "backup": result, taskErr = s.executeBackup(task.AgentID) case "update": - result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout) + result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout, task.SecurityOnly) default: taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action) } @@ -174,10 +174,10 @@ func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) { } func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) { - return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600) + return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600, false) } -func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int) (interface{}, error) { +func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int, securityOnly bool) (interface{}, error) { if !s.hub.IsAgentConnected(agentID) { return nil, fmt.Errorf("Agent nicht verbunden") } @@ -212,7 +212,7 @@ func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool "type": "command", "id": updateCmdID, "command": "update", - "params": map[string]interface{}{"reboot": reboot}, + "params": map[string]interface{}{"reboot": reboot, "security_only": securityOnly}, } updateCh := s.hub.RegisterResponseWaiter(updateCmdID) diff --git a/backend/api/tasks.go b/backend/api/tasks.go index c0bf497..b5f0d75 100644 --- a/backend/api/tasks.go +++ b/backend/api/tasks.go @@ -79,6 +79,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { Reboot bool `json:"reboot"` HealthCheck bool `json:"health_check"` HealthCheckTimeout int `json:"health_check_timeout"` + SecurityOnly bool `json:"security_only"` WebhookURL string `json:"webhook_url"` Active *bool `json:"active"` } @@ -117,7 +118,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { req.AgentID, req.Action, req.ScheduledAt, req.WebhookURL, createdBy, req.Name, req.ScheduleType, req.ScheduleTime, req.ScheduleWeekday, req.ScheduleMonthday, - req.Reboot, req.HealthCheck, req.HealthCheckTimeout, active, + req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.SecurityOnly, active, ) if err != nil { log.Printf("Task erstellen fehlgeschlagen: %v", err) diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 741eaff..0f68b1e 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -261,6 +261,7 @@ func (d *Database) migrate() error { d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS reboot BOOLEAN DEFAULT FALSE`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS health_check BOOLEAN DEFAULT FALSE`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS health_check_timeout INT DEFAULT 600`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS security_only BOOLEAN DEFAULT FALSE`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT TRUE`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_run TIMESTAMPTZ`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_run TIMESTAMPTZ`) @@ -759,6 +760,9 @@ func (d *Database) GetAllInstallerInfo() ([]map[string]interface{}, error) { } func (d *Database) DeleteAgent(id string) (bool, error) { + // metrics hat keinen FK-Cascade → manuell löschen + d.db.Exec("DELETE FROM metrics WHERE agent_id = $1", id) + // agents löschen → CASCADE löscht system_data, tasks, config_backups, agent_events res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id) if err != nil { return false, err diff --git a/backend/db/tasks.go b/backend/db/tasks.go index 13546f4..42b2861 100644 --- a/backend/db/tasks.go +++ b/backend/db/tasks.go @@ -62,7 +62,7 @@ func CalculateNextRun(scheduleType, scheduleTime string, weekday, monthday *int, func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string, name, scheduleType, scheduleTime string, weekday, monthday *int, - reboot, healthCheck bool, healthCheckTimeout int, active bool) (*models.Task, error) { + reboot, healthCheck bool, healthCheckTimeout int, securityOnly bool, active bool) (*models.Task, error) { validActions := map[string]bool{"backup": true, "update": true} if !validActions[action] { @@ -107,12 +107,12 @@ func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdB err := d.db.QueryRow(` INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by, schedule_type, schedule_time, schedule_weekday, schedule_monthday, - reboot, health_check, health_check_timeout, active, next_run) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + reboot, health_check, health_check_timeout, security_only, active, next_run) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id `, agentID, name, action, schedTime, webhookURL, createdBy, scheduleType, scheduleTime, weekday, monthday, - reboot, healthCheck, healthCheckTimeout, active, nextRun).Scan(&taskID) + reboot, healthCheck, healthCheckTimeout, securityOnly, active, nextRun).Scan(&taskID) if err != nil { return nil, fmt.Errorf("Task erstellen: %w", err) } @@ -129,7 +129,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) { var agentName, customerName, customerNumber sql.NullString var name, scheduleType, scheduleTime sql.NullString var scheduleWeekday, scheduleMonthday sql.NullInt32 - var reboot, healthCheck, active sql.NullBool + var reboot, healthCheck, securityOnly, active sql.NullBool var healthCheckTimeout, runCount sql.NullInt32 err := scan( @@ -138,7 +138,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) { &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, &createdBy, &createdAt, &scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday, - &reboot, &healthCheck, &healthCheckTimeout, &active, &lastRun, &nextRun, &runCount, + &reboot, &healthCheck, &healthCheckTimeout, &securityOnly, &active, &lastRun, &nextRun, &runCount, ) if err != nil { return nil, err @@ -170,6 +170,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) { if healthCheckTimeout.Valid { t.HealthCheckTimeout = int(healthCheckTimeout.Int32) } + t.SecurityOnly = securityOnly.Valid && securityOnly.Bool t.Active = !active.Valid || active.Bool t.RunCount = int(runCount.Int32) @@ -214,7 +215,7 @@ const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, t.result, t.webhook_url, t.webhook_sent, t.webhook_response, t.created_by, t.created_at, t.schedule_type, t.schedule_time, t.schedule_weekday, t.schedule_monthday, - t.reboot, t.health_check, t.health_check_timeout, t.active, t.last_run, t.next_run, t.run_count` + t.reboot, t.health_check, t.health_check_timeout, COALESCE(t.security_only, FALSE), t.active, t.last_run, t.next_run, t.run_count` const taskFromJoin = `FROM tasks t LEFT JOIN agents a ON a.id = t.agent_id diff --git a/backend/models/system.go b/backend/models/system.go index d04dd2d..6ef805c 100644 --- a/backend/models/system.go +++ b/backend/models/system.go @@ -157,16 +157,27 @@ type PluginInfo struct { } type UpdateInfo struct { - UpdateAvailable bool `json:"update_available"` - PendingCount int `json:"pending_count"` + UpdateAvailable bool `json:"update_available"` + PendingCount int `json:"pending_count"` + SecurityCount int `json:"security_count,omitempty"` Updates []PendingUpdateInfo `json:"updates"` - OPNsenseUpdate string `json:"opnsense_update,omitempty"` + OPNsenseUpdate string `json:"opnsense_update,omitempty"` } type PendingUpdateInfo struct { Package string `json:"package"` + Arch string `json:"arch,omitempty"` CurrentVer string `json:"current_version"` NewVer string `json:"new_version"` + Repository string `json:"repository,omitempty"` + Security bool `json:"security,omitempty"` +} + +type UpdateInfoFull struct { + UpdateAvailable bool `json:"update_available"` + PendingCount int `json:"pending_count"` + SecurityCount int `json:"security_count,omitempty"` + Updates []PendingUpdateInfo `json:"updates"` } type CronJob struct { diff --git a/backend/models/task.go b/backend/models/task.go index 5d98151..3d2dd9f 100644 --- a/backend/models/task.go +++ b/backend/models/task.go @@ -20,6 +20,7 @@ type Task struct { Reboot bool `json:"reboot"` HealthCheck bool `json:"health_check"` HealthCheckTimeout int `json:"health_check_timeout"` + SecurityOnly bool `json:"security_only,omitempty"` WebhookURL string `json:"webhook_url,omitempty"` WebhookSent bool `json:"webhook_sent"` WebhookResponse string `json:"webhook_response,omitempty"` diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 0a66f7c..19e2d3d 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -38,7 +38,7 @@ const subItems = { ], system: [ { id: 'services', label: 'Dienste', icon: Settings }, - { id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd'] }, + { id: 'updates', label: 'Updates', icon: Download, platforms: ['freebsd', 'linux'] }, { id: 'agent', label: 'Agent', icon: Cpu }, ], } @@ -117,9 +117,9 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { if (tab === 'dhcp') return if (tab === 'gateways') return if (tab === 'certs') return - if (tab === 'updates') return + if (tab === 'updates') return platform === 'linux' ? : if (tab === 'security') return - if (tab === 'tasks') return + if (tab === 'tasks') return if (tab === 'backups') return platform === 'linux' ? : if (tab === 'agent') return if (tab === 'haproxy') return @@ -566,57 +566,129 @@ function StatusCard({ icon: Icon, value, label }) { ) } -function IfaceChart({ agentId, ifaceName }) { +const IFACE_RANGES = [ + { label: '8h', hours: 8, limit: 200 }, + { label: '24h', hours: 24, limit: 400 }, + { label: '7d', hours: 24 * 7, limit: 500 }, +] + +function IfaceChartSingle({ agentId, ifaceName, range }) { const [rxData, setRxData] = useState([]) const [txData, setTxData] = useState([]) useEffect(() => { if (!agentId || !ifaceName) return - const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString() - const params = `&from=${from}&limit=200&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}` + setRxData([]) + setTxData([]) + const from = new Date(Date.now() - range.hours * 60 * 60 * 1000).toISOString() + const params = `&from=${from}&limit=${range.limit}&tags=${encodeURIComponent(JSON.stringify({ interface: ifaceName }))}` api.getMetrics(agentId, 'interface_rx_bps', params).then(d => { if (Array.isArray(d)) setRxData(d) }).catch(() => {}) api.getMetrics(agentId, 'interface_tx_bps', params).then(d => { if (Array.isArray(d)) setTxData(d) }).catch(() => {}) - }, [agentId, ifaceName]) - - if (rxData.length < 2 && txData.length < 2) { - return
Noch keine Verlaufsdaten (ab dem nächsten Heartbeat)
- } - - // Gemeinsame SVG mit zwei Linien (RX blau, TX grün) - const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)] - const maxVal = Math.max(...allVals, 1) - const w = 100, h = 50 - const toPath = (data) => data.map((d, i) => { - const x = (i / (data.length - 1)) * w - const y = h - (d.value / maxVal) * (h - 4) - 2 - return `${x.toFixed(1)},${y.toFixed(1)}` - }).join(' ') + }, [agentId, ifaceName, range]) const formatBps = (v) => { + if (v >= 1024 * 1024 * 1024) return `${(v / 1024 / 1024 / 1024).toFixed(1)} GB/s` if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s` if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s` - return `${v.toFixed(0)} B/s` + return `${Math.round(v)} B/s` } + + if (rxData.length < 2 && txData.length < 2) { + return ( +
+ {range.label} +
Keine Daten
+
+ ) + } + + const allVals = [...rxData.map(d => d.value), ...txData.map(d => d.value)] + const maxVal = Math.max(...allVals, 1) + const magnitude = Math.pow(10, Math.floor(Math.log10(maxVal))) + const niceMax = Math.ceil(maxVal / magnitude) * magnitude + + const PAD_LEFT = 56 + const PAD_RIGHT = 8 + const PAD_TOP = 8 + const PAD_BOTTOM = 22 + const SVG_W = 600 + const SVG_H = 130 + const chartW = SVG_W - PAD_LEFT - PAD_RIGHT + const chartH = SVG_H - PAD_TOP - PAD_BOTTOM + + const toX = (i, len) => PAD_LEFT + (i / Math.max(len - 1, 1)) * chartW + const toY = (val) => PAD_TOP + chartH - (val / niceMax) * chartH + const toPath = (data) => data.map((d, i) => `${toX(i, data.length).toFixed(1)},${toY(d.value).toFixed(1)}`).join(' ') + + const yTicks = [0, 0.5, 1.0].map(f => ({ val: niceMax * f, y: toY(niceMax * f) })) + + const fmtLabel = (iso) => { + if (!iso) return '' + const d = new Date(iso) + if (range.hours > 24) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + } + const allData = rxData.length >= txData.length ? rxData : txData + const xTicks = allData.length >= 2 + ? [ + { label: fmtLabel(allData[0]?.time), x: toX(0, allData.length) }, + { label: fmtLabel(allData[Math.floor(allData.length / 2)]?.time), x: toX(Math.floor(allData.length / 2), allData.length) }, + { label: fmtLabel(allData.at(-1)?.time), x: toX(allData.length - 1, allData.length) }, + ] + : [] + const lastRx = rxData.at(-1)?.value || 0 const lastTx = txData.at(-1)?.value || 0 return ( -
+
- Durchsatz letzte 8h + {range.label}
↓ {formatBps(lastRx)} ↑ {formatBps(lastTx)}
- + + {yTicks.map(({ val, y }) => ( + + + {formatBps(val)} + + ))} + {rxData.length >= 2 && `${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 && `${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 && } {txData.length >= 2 && } + + {xTicks.map(({ label, x }, idx) => ( + {label} + ))}
) } +function IfaceChart({ agentId, ifaceName }) { + return ( +
+
+ RX + TX +
+ {IFACE_RANGES.map(r => ( + + ))} +
+ ) +} + +function fmtTime(iso) { + if (!iso) return '' + const d = new Date(iso) + return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +} + function InterfacesTab({ sys, agentId }) { const [expanded, setExpanded] = useState(null) const ifaces = sys.network_interfaces || [] @@ -1771,7 +1843,7 @@ function SecurityTab({ sys }) { ) } -function TasksTab({ agentId, agentName }) { +function TasksTab({ agentId, agentName, platform }) { const [tasks, setTasks] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) @@ -1780,52 +1852,83 @@ function TasksTab({ agentId, agentName }) { const [creating, setCreating] = useState(false) const [error, setError] = useState('') const [showWebhook, setShowWebhook] = useState(false) + const isLinux = platform === 'linux' const defaultForm = { - name: '', action: 'backup', schedule_type: 'once', schedule_time: '02:00', + name: '', action: isLinux ? 'update' : 'backup', schedule_type: 'once', schedule_time: '02:00', scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1, reboot: false, health_check: false, health_check_timeout: 600, + security_only: false, webhook_url: '', active: true, } - const jobTemplates = [ + const jobTemplatesFreeBSD = [ { label: 'Wöchentliches Update', - icon: '🔄', form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly', schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true, - health_check_timeout: 600, webhook_url: '', active: true }, + health_check_timeout: 600, active: true }, }, { label: 'Monatliches Update', - icon: '📅', form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, - health_check_timeout: 600, webhook_url: '', active: true }, + health_check_timeout: 600, active: true }, }, { label: 'Tägliches Backup', - icon: '💾', form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily', - schedule_time: '03:00', reboot: false, health_check: false, - health_check_timeout: 600, webhook_url: '', active: true }, + schedule_time: '03:00', reboot: false, health_check: false, active: true }, }, { label: 'Monatliches Backup', - icon: '🗄️', form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly', - schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, - health_check_timeout: 600, webhook_url: '', active: true }, + schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, active: true }, }, { label: 'Update + ERP-Webhook', - icon: '🔗', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, - health_check_timeout: 600, webhook_url: '', active: true }, + health_check_timeout: 600, active: true }, showWebhook: true, }, ] + + const jobTemplatesLinux = [ + { + label: 'Wöchentlich alle Updates', + form: { name: 'Wöchentliches Debian-Update', action: 'update', schedule_type: 'weekly', + schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true, + health_check_timeout: 600, security_only: false, active: true }, + }, + { + label: 'Monatlich alle Updates', + form: { name: 'Monatliches Debian-Update', action: 'update', schedule_type: 'monthly', + schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, + health_check_timeout: 600, security_only: false, active: true }, + }, + { + label: 'Wöchentlich nur Security', + form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly', + schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true, + health_check_timeout: 300, security_only: true, active: true }, + }, + { + label: 'Täglich Security (kein Reboot)', + form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily', + schedule_time: '04:00', reboot: false, health_check: false, + security_only: true, active: true }, + }, + { + label: 'Update + Webhook', + form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', + schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, + health_check_timeout: 600, security_only: false, active: true }, + showWebhook: true, + }, + ] + + const jobTemplates = isLinux ? jobTemplatesLinux : jobTemplatesFreeBSD const [form, setForm] = useState(defaultForm) const loadTasks = () => { @@ -1850,6 +1953,7 @@ function TasksTab({ agentId, agentName }) { if (form.action === 'update') { data.reboot = form.reboot; data.health_check = form.health_check if (form.health_check) data.health_check_timeout = parseInt(form.health_check_timeout) || 600 + if (isLinux) data.security_only = form.security_only || false } if (form.webhook_url) data.webhook_url = form.webhook_url await api.createTask(data) @@ -1997,10 +2101,10 @@ function TasksTab({ agentId, agentName }) {
- {['backup', 'update'].map(a => ( + {(isLinux ? ['update'] : ['backup', 'update']).map(a => ( ))}
@@ -2008,6 +2112,12 @@ function TasksTab({ agentId, agentName }) { )} {form.action === 'update' && (
+ {isLinux && ( + + )}