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)}
-
)
}
+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 && (
+
+ )}