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 }) {
| ID | +Aktion | +Status | +Geplant | +Gestartet | +Abgeschlossen | +Webhook | +Aktionen | +
|---|---|---|---|---|---|---|---|
| {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 === 'scheduled' && (
+
+ )}
+
+
+ |
+