From 7908ae26a0ce93ee67a83b3db2871597069ef8da Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Thu, 5 Mar 2026 08:59:53 +0100 Subject: [PATCH] Feature: Task Scheduler - planned tasks with webhook support --- backend/api/handlers.go | 7 + backend/api/scheduler.go | 244 +++++++++++++++++++ backend/api/tasks.go | 162 ++++++++++++ backend/db/postgres.go | 18 ++ backend/db/tasks.go | 325 +++++++++++++++++++++++++ backend/main.go | 4 + backend/models/task.go | 19 ++ frontend/src/api/client.js | 23 ++ frontend/src/components/AgentPanel.jsx | 235 +++++++++++++++++- 9 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 backend/api/scheduler.go create mode 100644 backend/api/tasks.go create mode 100644 backend/db/tasks.go create mode 100644 backend/models/task.go diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 3ca14ce..ee488a9 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -80,6 +80,13 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("POST /api/v1/installer/upload", h.uploadInstaller) mux.HandleFunc("GET /api/v1/installer/download", h.downloadInstaller) + // Task-Routes + mux.HandleFunc("GET /api/v1/tasks", h.listTasks) + mux.HandleFunc("GET /api/v1/tasks/{id}", h.getTask) + mux.HandleFunc("POST /api/v1/tasks", h.createTask(hub)) + mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask) + mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask) + // WebSocket und Tunnel-Routes h.setupTunnelRoutes(mux, hub) } diff --git a/backend/api/scheduler.go b/backend/api/scheduler.go new file mode 100644 index 0000000..9a74823 --- /dev/null +++ b/backend/api/scheduler.go @@ -0,0 +1,244 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/cynfo/rmm-backend/db" + "github.com/cynfo/rmm-backend/ws" +) + +type TaskScheduler struct { + db *db.Database + hub *ws.Hub +} + +func NewTaskScheduler(database *db.Database, hub *ws.Hub) *TaskScheduler { + return &TaskScheduler{db: database, hub: hub} +} + +func (s *TaskScheduler) Run() { + log.Println("Task-Scheduler gestartet (30s Intervall)") + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Run once immediately + s.processDueTasks() + + for range ticker.C { + s.processDueTasks() + } +} + +func (s *TaskScheduler) processDueTasks() { + tasks, err := s.db.GetDueTasks() + if err != nil { + log.Printf("Scheduler: Fehler beim Laden faelliger Tasks: %v", err) + return + } + + for _, task := range tasks { + go s.executeTask(task.ID, task.AgentID, task.Action, task.WebhookURL, task.AgentName, task.CustomerName, task.ScheduledAt) + } +} + +func (s *TaskScheduler) executeTask(taskID int, agentID, action, webhookURL, agentName, customerName, scheduledAt string) { + log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", taskID, action, agentID) + + // Set status to running + s.db.UpdateTaskStatus(taskID, "running", nil) + + var result interface{} + var taskErr error + + switch action { + case "backup": + result, taskErr = s.executeBackup(agentID) + case "update": + result, taskErr = s.executeUpdate(agentID) + default: + taskErr = fmt.Errorf("unbekannte Aktion: %s", action) + } + + // Update status + status := "completed" + if taskErr != nil { + status = "failed" + result = map[string]string{"error": taskErr.Error()} + log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", taskID, taskErr) + } else { + log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", taskID) + } + + s.db.UpdateTaskStatus(taskID, status, result) + + // Re-read task for webhook payload + task, _ := s.db.GetTask(taskID) + + // Send webhook if configured + if webhookURL != "" && task != nil { + s.sendWebhook(taskID, webhookURL, task) + } +} + +func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) { + if !s.hub.IsAgentConnected(agentID) { + return nil, fmt.Errorf("Agent nicht verbunden") + } + + cmdID := generateID() + cmd := map[string]interface{}{ + "type": "command", + "id": cmdID, + "command": "backup", + "params": map[string]interface{}{}, + } + + responseCh := s.hub.RegisterResponseWaiter(cmdID) + defer s.hub.UnregisterResponseWaiter(cmdID) + + cmdJSON, _ := json.Marshal(cmd) + if err := s.hub.SendToAgent(agentID, cmdJSON); err != nil { + return nil, fmt.Errorf("Command senden fehlgeschlagen: %w", err) + } + + select { + case resp := <-responseCh: + respStatus, _ := resp["status"].(string) + if respStatus != "ok" { + errMsg, _ := resp["error"].(string) + return resp, fmt.Errorf("Backup fehlgeschlagen: %s", errMsg) + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + return resp, fmt.Errorf("ungueltiges Response-Format") + } + + // Save backup to DB (same logic as triggerBackup handler) + configB64, _ := data["config"].(string) + hash, _ := data["hash"].(string) + sizeFloat, _ := data["size"].(float64) + size := int(sizeFloat) + + configXML, err := base64.StdEncoding.DecodeString(configB64) + if err != nil { + return nil, fmt.Errorf("Base64-Dekodierung fehlgeschlagen") + } + + backupID, isNew, err := s.db.SaveConfigBackup(agentID, hash, size, string(configXML)) + if err != nil { + return nil, fmt.Errorf("Backup speichern fehlgeschlagen: %w", err) + } + + msg := "Backup gespeichert" + if !isNew { + msg = "Config unveraendert (Hash identisch)" + } + + return map[string]interface{}{ + "message": msg, + "backup_id": backupID, + "sha256": hash, + "size": size, + "new": isNew, + }, nil + + case <-time.After(60 * time.Second): + return nil, fmt.Errorf("Timeout: Agent hat nicht rechtzeitig geantwortet") + } +} + +func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) { + if !s.hub.IsAgentConnected(agentID) { + return nil, fmt.Errorf("Agent nicht verbunden") + } + + // First: update_check + checkCmdID := generateID() + checkCmd := map[string]interface{}{ + "type": "command", + "id": checkCmdID, + "command": "update_check", + } + + checkCh := s.hub.RegisterResponseWaiter(checkCmdID) + defer s.hub.UnregisterResponseWaiter(checkCmdID) + + checkJSON, _ := json.Marshal(checkCmd) + if err := s.hub.SendToAgent(agentID, checkJSON); err != nil { + return nil, fmt.Errorf("Update-Check senden fehlgeschlagen: %w", err) + } + + var checkResult map[string]interface{} + select { + case resp := <-checkCh: + checkResult = resp + case <-time.After(120 * time.Second): + return nil, fmt.Errorf("Timeout beim Update-Check") + } + + // Then: update + updateCmdID := generateID() + updateCmd := map[string]interface{}{ + "type": "command", + "id": updateCmdID, + "command": "update", + "params": map[string]interface{}{"reboot": false}, + } + + updateCh := s.hub.RegisterResponseWaiter(updateCmdID) + defer s.hub.UnregisterResponseWaiter(updateCmdID) + + updateJSON, _ := json.Marshal(updateCmd) + if err := s.hub.SendToAgent(agentID, updateJSON); err != nil { + return nil, fmt.Errorf("Update-Command senden fehlgeschlagen: %w", err) + } + + select { + case resp := <-updateCh: + respStatus, _ := resp["status"].(string) + if respStatus != "ok" { + errMsg, _ := resp["error"].(string) + return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg) + } + return map[string]interface{}{ + "message": "Update ausgefuehrt", + "check_result": checkResult, + "update_result": resp, + }, nil + case <-time.After(900 * time.Second): + return nil, fmt.Errorf("Timeout beim Update") + } +} + +func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) { + payload, err := json.Marshal(task) + if err != nil { + log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err) + s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("JSON-Fehler: %v", err)) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err) + s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Fehler: %v", err)) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + response := fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)) + + sent := resp.StatusCode >= 200 && resp.StatusCode < 300 + s.db.SetTaskWebhookResult(taskID, sent, response) + log.Printf("Scheduler: Webhook fuer Task #%d gesendet (Status: %d)", taskID, resp.StatusCode) +} diff --git a/backend/api/tasks.go b/backend/api/tasks.go new file mode 100644 index 0000000..9316e44 --- /dev/null +++ b/backend/api/tasks.go @@ -0,0 +1,162 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "github.com/cynfo/rmm-backend/ws" +) + +// GET /api/v1/tasks +func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) { + limit := 50 + offset := 0 + agentID := r.URL.Query().Get("agent_id") + status := r.URL.Query().Get("status") + action := r.URL.Query().Get("action") + + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n + } + } + if o := r.URL.Query().Get("offset"); o != "" { + if n, err := strconv.Atoi(o); err == nil && n >= 0 { + offset = n + } + } + + tasks, total, err := h.db.GetTasks(limit, offset, agentID, status, action) + if err != nil { + log.Printf("Tasks laden fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "data": tasks, + "total": total, + }) +} + +// GET /api/v1/tasks/{id} +func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Task-ID") + return + } + + task, err := h.db.GetTask(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if task == nil { + writeError(w, http.StatusNotFound, "Task nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, task) +} + +// POST /api/v1/tasks +func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + AgentID string `json:"agent_id"` + Action string `json:"action"` + ScheduledAt string `json:"scheduled_at"` + WebhookURL string `json:"webhook_url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") + return + } + + if req.AgentID == "" || req.Action == "" { + writeError(w, http.StatusBadRequest, "agent_id und action sind erforderlich") + return + } + + // Validate action + if req.Action != "backup" && req.Action != "update" { + writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update)") + return + } + + // Check agent exists + agent, _, err := h.db.GetAgent(req.AgentID) + if err != nil || agent == nil { + writeError(w, http.StatusNotFound, "Agent nicht gefunden") + return + } + + // Get user from context + createdBy := "api-key" + if claims, ok := r.Context().Value(UserContextKey).(*JWTClaims); ok { + createdBy = claims.Username + } + + task, err := h.db.CreateTask(req.AgentID, req.Action, req.ScheduledAt, req.WebhookURL, createdBy) + if err != nil { + log.Printf("Task erstellen fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Task erstellen fehlgeschlagen: "+err.Error()) + return + } + + h.auditLog(r, "task.create", "task", strconv.Itoa(task.ID), agent.Name, req.Action) + + // If scheduled_at is in the past or now, trigger immediate execution + if req.ScheduledAt == "" { + // Already scheduled for now, scheduler will pick it up within 30s + } else { + schedTime, err := time.Parse(time.RFC3339, req.ScheduledAt) + if err == nil && !schedTime.After(time.Now()) { + // In the past — scheduler picks it up + } + } + + writeJSON(w, http.StatusCreated, task) + } +} + +// POST /api/v1/tasks/{id}/cancel +func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Task-ID") + return + } + + if err := h.db.CancelTask(id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + h.auditLog(r, "task.cancel", "task", idStr, "", "") + writeJSON(w, http.StatusOK, map[string]string{"message": "Task abgebrochen"}) +} + +// DELETE /api/v1/tasks/{id} +func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Task-ID") + return + } + + if err := h.db.DeleteTask(id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + h.auditLog(r, "task.delete", "task", idStr, "", "") + writeJSON(w, http.StatusOK, map[string]string{"message": "Task geloescht"}) +} diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 7c51f96..5733068 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -227,6 +227,24 @@ func (d *Database) migrate() error { d.db.Exec(`INSERT INTO system_settings (key, value) VALUES ($1, '') ON CONFLICT DO NOTHING`, k) } + // Tasks Tabelle + d.db.Exec(`CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + action TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'scheduled', + scheduled_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + result JSONB, + webhook_url TEXT, + webhook_sent BOOLEAN DEFAULT FALSE, + webhook_response TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_status_scheduled ON tasks(status, scheduled_at)`) + // Retention Policy (90 Tage) d.setupRetention() diff --git a/backend/db/tasks.go b/backend/db/tasks.go new file mode 100644 index 0000000..68a8910 --- /dev/null +++ b/backend/db/tasks.go @@ -0,0 +1,325 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cynfo/rmm-backend/models" +) + +func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string) (*models.Task, error) { + // Validate action + validActions := map[string]bool{"backup": true, "update": true} + if !validActions[action] { + return nil, fmt.Errorf("ungueltige Aktion: %s", action) + } + + var schedTime time.Time + var err error + if scheduledAt == "" { + schedTime = time.Now() + } else { + schedTime, err = time.Parse(time.RFC3339, scheduledAt) + if err != nil { + return nil, fmt.Errorf("ungueltiges Datum: %w", err) + } + } + + var task models.Task + err = d.db.QueryRow(` + INSERT INTO tasks (agent_id, action, scheduled_at, webhook_url, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, agent_id, action, status, scheduled_at, webhook_url, COALESCE(created_by, ''), created_at + `, agentID, action, schedTime, webhookURL, createdBy).Scan( + &task.ID, &task.AgentID, &task.Action, &task.Status, + &schedTime, &task.WebhookURL, &task.CreatedBy, &schedTime, + ) + if err != nil { + return nil, fmt.Errorf("Task erstellen: %w", err) + } + + // Re-read cleanly + return d.GetTask(task.ID) +} + +func (d *Database) GetTask(id int) (*models.Task, error) { + var t models.Task + var scheduledAt, createdAt time.Time + var startedAt, finishedAt sql.NullTime + var resultJSON sql.NullString + var webhookURL, webhookResponse, createdBy sql.NullString + var agentName, customerName sql.NullString + + err := d.db.QueryRow(` + SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), + t.action, t.status, t.scheduled_at, t.started_at, t.finished_at, + t.result, t.webhook_url, t.webhook_sent, t.webhook_response, + t.created_by, t.created_at + FROM tasks t + LEFT JOIN agents a ON a.id = t.agent_id + LEFT JOIN customers c ON c.id = a.customer_id + WHERE t.id = $1 + `, id).Scan( + &t.ID, &t.AgentID, &agentName, &customerName, + &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt, + &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, + &createdBy, &createdAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + t.AgentName = agentName.String + t.CustomerName = customerName.String + t.ScheduledAt = scheduledAt.Format(time.RFC3339) + t.CreatedAt = createdAt.Format(time.RFC3339) + if startedAt.Valid { + s := startedAt.Time.Format(time.RFC3339) + t.StartedAt = &s + } + if finishedAt.Valid { + s := finishedAt.Time.Format(time.RFC3339) + t.FinishedAt = &s + } + if resultJSON.Valid { + var r interface{} + json.Unmarshal([]byte(resultJSON.String), &r) + t.Result = r + } + if webhookURL.Valid { + t.WebhookURL = webhookURL.String + } + if webhookResponse.Valid { + t.WebhookResponse = webhookResponse.String + } + if createdBy.Valid { + t.CreatedBy = createdBy.String + } + + return &t, nil +} + +func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ([]models.Task, int, error) { + if limit <= 0 { + limit = 50 + } + + where := []string{} + args := []interface{}{} + argIdx := 1 + + if agentID != "" { + where = append(where, fmt.Sprintf("t.agent_id = $%d", argIdx)) + args = append(args, agentID) + argIdx++ + } + if status != "" { + where = append(where, fmt.Sprintf("t.status = $%d", argIdx)) + args = append(args, status) + argIdx++ + } + if action != "" { + where = append(where, fmt.Sprintf("t.action = $%d", argIdx)) + args = append(args, action) + argIdx++ + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + // Count + var total int + countQuery := "SELECT COUNT(*) FROM tasks t " + whereClause + if err := d.db.QueryRow(countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // Data + query := fmt.Sprintf(` + SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), + t.action, t.status, t.scheduled_at, t.started_at, t.finished_at, + t.result, t.webhook_url, t.webhook_sent, t.webhook_response, + t.created_by, t.created_at + FROM tasks t + LEFT JOIN agents a ON a.id = t.agent_id + LEFT JOIN customers c ON c.id = a.customer_id + %s ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d + `, whereClause, argIdx, argIdx+1) + args = append(args, limit, offset) + + rows, err := d.db.Query(query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var tasks []models.Task + for rows.Next() { + var t models.Task + var scheduledAt, createdAt time.Time + var startedAt, finishedAt sql.NullTime + var resultJSON sql.NullString + var webhookURL, webhookResponse, createdBy sql.NullString + var agentName, customerName sql.NullString + + if err := rows.Scan( + &t.ID, &t.AgentID, &agentName, &customerName, + &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt, + &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, + &createdBy, &createdAt, + ); err != nil { + return nil, 0, err + } + + t.AgentName = agentName.String + t.CustomerName = customerName.String + t.ScheduledAt = scheduledAt.Format(time.RFC3339) + t.CreatedAt = createdAt.Format(time.RFC3339) + if startedAt.Valid { + s := startedAt.Time.Format(time.RFC3339) + t.StartedAt = &s + } + if finishedAt.Valid { + s := finishedAt.Time.Format(time.RFC3339) + t.FinishedAt = &s + } + if resultJSON.Valid { + var r interface{} + json.Unmarshal([]byte(resultJSON.String), &r) + t.Result = r + } + if webhookURL.Valid { + t.WebhookURL = webhookURL.String + } + if webhookResponse.Valid { + t.WebhookResponse = webhookResponse.String + } + if createdBy.Valid { + t.CreatedBy = createdBy.String + } + + tasks = append(tasks, t) + } + if tasks == nil { + tasks = []models.Task{} + } + return tasks, total, rows.Err() +} + +func (d *Database) UpdateTaskStatus(id int, status string, result interface{}) error { + var resultJSON *string + if result != nil { + b, err := json.Marshal(result) + if err == nil { + s := string(b) + resultJSON = &s + } + } + + now := time.Now().UTC() + + switch status { + case "running": + _, err := d.db.Exec( + "UPDATE tasks SET status = $1, started_at = $2 WHERE id = $3", + status, now, id, + ) + return err + case "completed", "failed": + _, err := d.db.Exec( + "UPDATE tasks SET status = $1, finished_at = $2, result = $3 WHERE id = $4", + status, now, resultJSON, id, + ) + return err + default: + _, err := d.db.Exec("UPDATE tasks SET status = $1 WHERE id = $2", status, id) + return err + } +} + +func (d *Database) SetTaskWebhookResult(id int, sent bool, response string) error { + // Truncate response to 500 chars + if len(response) > 500 { + response = response[:500] + } + _, err := d.db.Exec( + "UPDATE tasks SET webhook_sent = $1, webhook_response = $2 WHERE id = $3", + sent, response, id, + ) + return err +} + +func (d *Database) GetDueTasks() ([]models.Task, error) { + rows, err := d.db.Query(` + SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), + t.action, t.status, t.scheduled_at, t.webhook_url, t.created_at + FROM tasks t + LEFT JOIN agents a ON a.id = t.agent_id + LEFT JOIN customers c ON c.id = a.customer_id + WHERE t.status = 'scheduled' AND t.scheduled_at <= NOW() + ORDER BY t.scheduled_at ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []models.Task + for rows.Next() { + var t models.Task + var scheduledAt, createdAt time.Time + var webhookURL sql.NullString + var agentName, customerName sql.NullString + + if err := rows.Scan( + &t.ID, &t.AgentID, &agentName, &customerName, + &t.Action, &t.Status, &scheduledAt, &webhookURL, &createdAt, + ); err != nil { + return nil, err + } + t.AgentName = agentName.String + t.CustomerName = customerName.String + t.ScheduledAt = scheduledAt.Format(time.RFC3339) + t.CreatedAt = createdAt.Format(time.RFC3339) + if webhookURL.Valid { + t.WebhookURL = webhookURL.String + } + tasks = append(tasks, t) + } + return tasks, rows.Err() +} + +func (d *Database) CancelTask(id int) error { + res, err := d.db.Exec( + "UPDATE tasks SET status = 'cancelled' WHERE id = $1 AND status = 'scheduled'", + id, + ) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows == 0 { + return fmt.Errorf("Task nicht gefunden oder nicht mehr im Status 'scheduled'") + } + return nil +} + +func (d *Database) DeleteTask(id int) error { + res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows == 0 { + return fmt.Errorf("Task nicht gefunden") + } + return nil +} diff --git a/backend/main.go b/backend/main.go index 1b934a8..0741b19 100644 --- a/backend/main.go +++ b/backend/main.go @@ -57,6 +57,10 @@ func main() { mon := monitor.New(database) go mon.Run() + // Task-Scheduler starten + scheduler := api.NewTaskScheduler(database, hub) + go scheduler.Run() + // Config API-Keys in DB migrieren (einmalig) database.MigrateConfigAPIKeys(cfg.APIKeys) diff --git a/backend/models/task.go b/backend/models/task.go new file mode 100644 index 0000000..c645f01 --- /dev/null +++ b/backend/models/task.go @@ -0,0 +1,19 @@ +package models + +type Task struct { + ID int `json:"id"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` + CustomerName string `json:"customer_name,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + ScheduledAt string `json:"scheduled_at"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + Result interface{} `json:"result,omitempty"` + WebhookURL string `json:"webhook_url,omitempty"` + WebhookSent bool `json:"webhook_sent"` + WebhookResponse string `json:"webhook_response,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at"` +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 901d58c..6c99959 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -292,6 +292,29 @@ class ApiClient { return this.post('/api/v1/agents/request-update-all') } + // Tasks + getTasks(params = {}) { + const q = new URLSearchParams() + if (params.limit) q.set('limit', params.limit) + if (params.offset) q.set('offset', params.offset) + if (params.agent_id) q.set('agent_id', params.agent_id) + if (params.status) q.set('status', params.status) + if (params.action) q.set('action', params.action) + return this.get(`/api/v1/tasks?${q.toString()}`) + } + + createTask(data) { + return this.post('/api/v1/tasks', data) + } + + cancelTask(id) { + return this.post(`/api/v1/tasks/${id}/cancel`) + } + + deleteTask(id) { + return this.del(`/api/v1/tasks/${id}`) + } + // Installer getInstallerInfo() { return this.get('/api/v1/installer') diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 02bcb2a..b417a0e 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -5,7 +5,7 @@ import { copyToClipboard } from '../utils/clipboard' import { useSettingsStore } from '../stores/settings' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, - Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, + Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban, } from 'lucide-react' const allTabs = [ @@ -20,6 +20,7 @@ const allTabs = [ { id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] }, { id: 'updates', label: 'Updates', platforms: ['freebsd'] }, { id: 'security', label: 'Sicherheit', platforms: ['freebsd'] }, + { id: 'tasks', label: 'Aufgaben', platforms: ['freebsd', 'linux'] }, { id: 'backups', label: 'Backups' }, { id: 'agent', label: 'Agent' }, ] @@ -129,6 +130,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { ) : tab === 'security' ? ( + ) : tab === 'tasks' ? ( + ) : tab === 'backups' ? ( platform === 'linux' ? : ) : tab === 'agent' ? ( @@ -1494,6 +1497,236 @@ function SecurityTab({ sys }) { ) } +function TasksTab({ agentId }) { + const [tasks, setTasks] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [showDialog, setShowDialog] = useState(false) + const [newTask, setNewTask] = useState({ action: 'backup', scheduled_at: '', webhook_url: '' }) + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + + const loadTasks = () => { + api.getTasks({ agent_id: agentId, limit: 50 }) + .then(resp => { + setTasks(resp.data || []) + setTotal(resp.total || 0) + }) + .catch(() => setTasks([])) + .finally(() => setLoading(false)) + } + + useEffect(() => { loadTasks() }, [agentId]) + + // Auto-refresh every 15s + useEffect(() => { + const iv = setInterval(loadTasks, 15000) + return () => clearInterval(iv) + }, [agentId]) + + const handleCreate = async () => { + setCreating(true) + setError('') + try { + const data = { + agent_id: agentId, + action: newTask.action, + } + if (newTask.scheduled_at) { + // Convert datetime-local to RFC3339 + const d = new Date(newTask.scheduled_at) + data.scheduled_at = d.toISOString() + } + if (newTask.webhook_url) data.webhook_url = newTask.webhook_url + await api.createTask(data) + setShowDialog(false) + setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' }) + loadTasks() + } catch (e) { + setError(e.message) + } finally { + setCreating(false) + } + } + + const handleCancel = async (id) => { + if (!confirm('Task wirklich abbrechen?')) return + try { + await api.cancelTask(id) + loadTasks() + } catch (e) { + setError(e.message) + } + } + + const handleDelete = async (id) => { + if (!confirm('Task wirklich loeschen?')) return + try { + await api.deleteTask(id) + loadTasks() + } catch (e) { + setError(e.message) + } + } + + const statusBadge = (status) => { + const styles = { + scheduled: 'bg-gray-700 text-gray-300', + running: 'bg-blue-900/50 text-blue-400 border border-blue-700/50', + completed: 'bg-green-900/40 text-green-400 border border-green-700/50', + failed: 'bg-red-900/40 text-red-400 border border-red-700/50', + cancelled: 'bg-yellow-900/40 text-yellow-400 border border-yellow-700/50', + } + const labels = { scheduled: 'Geplant', running: 'Laeuft', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' } + return ( + + {status === 'running' && } + {labels[status] || status} + + ) + } + + 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' }) : '—' + + return ( +
+
+
{total} Aufgabe(n)
+ +
+ + {/* Create Dialog */} + {showDialog && ( +
+
+
+ + +
+
+ + setNewTask({ ...newTask, scheduled_at: e.target.value })} + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500" + /> +
+
+
+ + setNewTask({ ...newTask, webhook_url: e.target.value })} + placeholder="https://..." + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" + /> +
+ {error &&
{error}
} +
+ + +
+
+ )} + + {/* Task Table */} + {loading ? ( +
Laden...
+ ) : tasks.length === 0 ? ( +
+ Keine Aufgaben vorhanden +
+ ) : ( +
+ + + + + + + + + + + + + + + {tasks.map(t => ( + + + + + + + + + + + ))} + +
IDAktionStatusGeplantGestartetAbgeschlossenWebhookAktionen
{t.id}{t.action}{statusBadge(t.status)}{fmtDate(t.scheduled_at)}{fmtDate(t.started_at)}{fmtDate(t.finished_at)} + {t.webhook_url ? ( + t.webhook_sent ? ( + + ) : t.status === 'completed' || t.status === 'failed' ? ( + + ) : ( + + ) + ) : null} + +
+ {t.status === 'scheduled' && ( + + )} + +
+
+
+ )} +
+ ) +} + function BackupsTab({ agentId }) { const [backups, setBackups] = useState([]) const [loading, setLoading] = useState(true)