diff --git a/backend/api/handlers.go b/backend/api/handlers.go index ee488a9..f678956 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -86,6 +86,7 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { 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) + mux.HandleFunc("PUT /api/v1/tasks/{id}/toggle", h.toggleTask) // WebSocket und Tunnel-Routes h.setupTunnelRoutes(mux, hub) diff --git a/backend/api/scheduler.go b/backend/api/scheduler.go index 9a74823..82a1b0a 100644 --- a/backend/api/scheduler.go +++ b/backend/api/scheduler.go @@ -11,6 +11,7 @@ import ( "time" "github.com/cynfo/rmm-backend/db" + "github.com/cynfo/rmm-backend/models" "github.com/cynfo/rmm-backend/ws" ) @@ -44,46 +45,46 @@ func (s *TaskScheduler) processDueTasks() { } for _, task := range tasks { - go s.executeTask(task.ID, task.AgentID, task.Action, task.WebhookURL, task.AgentName, task.CustomerName, task.ScheduledAt) + go s.executeTask(task) } } -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) +func (s *TaskScheduler) executeTask(task models.Task) { + log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", task.ID, task.Action, task.AgentID) // Set status to running - s.db.UpdateTaskStatus(taskID, "running", nil) + s.db.UpdateTaskStatus(task.ID, "running", nil) var result interface{} var taskErr error - switch action { + switch task.Action { case "backup": - result, taskErr = s.executeBackup(agentID) + result, taskErr = s.executeBackup(task.AgentID) case "update": - result, taskErr = s.executeUpdate(agentID) + result, taskErr = s.executeUpdate(task.AgentID, task.Reboot) default: - taskErr = fmt.Errorf("unbekannte Aktion: %s", action) + taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action) } - // Update status - status := "completed" - if taskErr != nil { - status = "failed" + success := taskErr == nil + if !success { result = map[string]string{"error": taskErr.Error()} - log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", taskID, taskErr) + log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", task.ID, taskErr) } else { - log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", taskID) + log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", task.ID) } - s.db.UpdateTaskStatus(taskID, status, result) + // Use CompleteRecurringTask for proper recurring handling + s.db.CompleteRecurringTask(task.ID, task.ScheduleType, task.ScheduleTime, + task.ScheduleWeekday, task.ScheduleMonthday, success, result) // Re-read task for webhook payload - task, _ := s.db.GetTask(taskID) + fullTask, _ := s.db.GetTask(task.ID) // Send webhook if configured - if webhookURL != "" && task != nil { - s.sendWebhook(taskID, webhookURL, task) + if task.WebhookURL != "" && fullTask != nil { + s.sendWebhook(task.ID, task.WebhookURL, fullTask) } } @@ -121,7 +122,6 @@ func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) { 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) @@ -155,7 +155,7 @@ func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) { } } -func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) { +func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) { if !s.hub.IsAgentConnected(agentID) { return nil, fmt.Errorf("Agent nicht verbunden") } @@ -190,7 +190,7 @@ func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) { "type": "command", "id": updateCmdID, "command": "update", - "params": map[string]interface{}{"reboot": false}, + "params": map[string]interface{}{"reboot": reboot}, } updateCh := s.hub.RegisterResponseWaiter(updateCmdID) @@ -209,9 +209,10 @@ func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) { return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg) } return map[string]interface{}{ - "message": "Update ausgefuehrt", - "check_result": checkResult, + "message": "Update ausgefuehrt", + "check_result": checkResult, "update_result": resp, + "reboot": reboot, }, nil case <-time.After(900 * time.Second): return nil, fmt.Errorf("Timeout beim Update") diff --git a/backend/api/tasks.go b/backend/api/tasks.go index 9316e44..3080b58 100644 --- a/backend/api/tasks.go +++ b/backend/api/tasks.go @@ -68,10 +68,19 @@ func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) { 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"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + Action string `json:"action"` + ScheduledAt string `json:"scheduled_at"` + ScheduleType string `json:"schedule_type"` + ScheduleTime string `json:"schedule_time"` + ScheduleWeekday *int `json:"schedule_weekday"` + ScheduleMonthday *int `json:"schedule_monthday"` + Reboot bool `json:"reboot"` + HealthCheck bool `json:"health_check"` + HealthCheckTimeout int `json:"health_check_timeout"` + WebhookURL string `json:"webhook_url"` + Active *bool `json:"active"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") @@ -83,26 +92,33 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { 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) + active := true + if req.Active != nil { + active = *req.Active + } + + task, err := h.db.CreateTask( + 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, + ) if err != nil { log.Printf("Task erstellen fehlgeschlagen: %v", err) writeError(w, http.StatusInternalServerError, "Task erstellen fehlgeschlagen: "+err.Error()) @@ -111,13 +127,11 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { 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 + // If once and scheduled_at is in the past or now, scheduler picks it up within 30s + if req.ScheduledAt != "" && req.ScheduleType == "once" { + if schedTime, err := time.Parse(time.RFC3339, req.ScheduledAt); err == nil && !schedTime.After(time.Now()) { + // scheduler will pick it up + _ = schedTime } } @@ -125,6 +139,29 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc { } } +// PUT /api/v1/tasks/{id}/toggle +func (h *Handler) toggleTask(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.ToggleTask(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Umschalten") + return + } + if task == nil { + writeError(w, http.StatusNotFound, "Task nicht gefunden") + return + } + + h.auditLog(r, "task.toggle", "task", idStr, "", "") + writeJSON(w, http.StatusOK, task) +} + // POST /api/v1/tasks/{id}/cancel func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 5733068..3ed9562 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -245,6 +245,21 @@ func (d *Database) migrate() error { )`) d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_status_scheduled ON tasks(status, scheduled_at)`) + // Tasks: recurring jobs columns + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT DEFAULT ''`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_type TEXT DEFAULT 'once'`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_time TEXT DEFAULT '02:00'`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_weekday INT`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_monthday INT`) + 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 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`) + d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS run_count INT DEFAULT 0`) + d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`) + // Retention Policy (90 Tage) d.setupRetention() diff --git a/backend/db/tasks.go b/backend/db/tasks.go index 68a8910..4abb1dc 100644 --- a/backend/db/tasks.go +++ b/backend/db/tasks.go @@ -10,73 +10,168 @@ import ( "github.com/cynfo/rmm-backend/models" ) -func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string) (*models.Task, error) { - // Validate action +// CalculateNextRun computes the next run time based on schedule parameters. +func CalculateNextRun(scheduleType, scheduleTime string, weekday, monthday *int, after time.Time) time.Time { + h, m := 2, 0 + fmt.Sscanf(scheduleTime, "%d:%d", &h, &m) + + loc := after.Location() + + switch scheduleType { + case "daily": + next := time.Date(after.Year(), after.Month(), after.Day(), h, m, 0, 0, loc) + if !next.After(after) { + next = next.AddDate(0, 0, 1) + } + return next + + case "weekly": + wd := 1 // Monday default + if weekday != nil { + wd = *weekday + } + next := time.Date(after.Year(), after.Month(), after.Day(), h, m, 0, 0, loc) + // Find next occurrence of weekday (0=Sun, 1=Mon, ..., 6=Sat) + targetWd := time.Weekday(wd % 7) + for next.Weekday() != targetWd || !next.After(after) { + next = next.AddDate(0, 0, 1) + } + return next + + case "monthly": + md := 1 + if monthday != nil { + md = *monthday + } + if md < 1 { + md = 1 + } + if md > 28 { + md = 28 + } + next := time.Date(after.Year(), after.Month(), md, h, m, 0, 0, loc) + if !next.After(after) { + next = time.Date(after.Year(), after.Month()+1, md, h, m, 0, 0, loc) + } + return next + + default: // "once" + return after + } +} + +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) { + 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) - } + if scheduleType == "" { + scheduleType = "once" + } + if scheduleTime == "" { + scheduleTime = "02:00" + } + if healthCheckTimeout <= 0 { + healthCheckTimeout = 600 } - 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, - ) + now := time.Now() + var schedTime time.Time + + if scheduleType == "once" { + if scheduledAt == "" { + schedTime = now + } else { + var err error + schedTime, err = time.Parse(time.RFC3339, scheduledAt) + if err != nil { + return nil, fmt.Errorf("ungueltiges Datum: %w", err) + } + } + } else { + schedTime = now + } + + var nextRun time.Time + if scheduleType == "once" { + nextRun = schedTime + } else { + nextRun = CalculateNextRun(scheduleType, scheduleTime, weekday, monthday, now) + } + + var taskID int + 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) + RETURNING id + `, agentID, name, action, schedTime, webhookURL, createdBy, + scheduleType, scheduleTime, weekday, monthday, + reboot, healthCheck, healthCheckTimeout, active, nextRun).Scan(&taskID) if err != nil { return nil, fmt.Errorf("Task erstellen: %w", err) } - // Re-read cleanly - return d.GetTask(task.ID) + return d.GetTask(taskID) } -func (d *Database) GetTask(id int) (*models.Task, error) { +func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) { var t models.Task var scheduledAt, createdAt time.Time - var startedAt, finishedAt sql.NullTime + var startedAt, finishedAt, lastRun, nextRun sql.NullTime var resultJSON sql.NullString var webhookURL, webhookResponse, createdBy sql.NullString var agentName, customerName sql.NullString + var name, scheduleType, scheduleTime sql.NullString + var scheduleWeekday, scheduleMonthday sql.NullInt32 + var reboot, healthCheck, active sql.NullBool + var healthCheckTimeout, runCount sql.NullInt32 - 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( + err := scan( &t.ID, &t.AgentID, &agentName, &customerName, - &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt, + &name, &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt, &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, &createdBy, &createdAt, + &scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday, + &reboot, &healthCheck, &healthCheckTimeout, &active, &lastRun, &nextRun, &runCount, ) - if err == sql.ErrNoRows { - return nil, nil - } if err != nil { return nil, err } t.AgentName = agentName.String t.CustomerName = customerName.String + t.Name = name.String + t.ScheduleType = scheduleType.String + if t.ScheduleType == "" { + t.ScheduleType = "once" + } + t.ScheduleTime = scheduleTime.String + if t.ScheduleTime == "" { + t.ScheduleTime = "02:00" + } + if scheduleWeekday.Valid { + v := int(scheduleWeekday.Int32) + t.ScheduleWeekday = &v + } + if scheduleMonthday.Valid { + v := int(scheduleMonthday.Int32) + t.ScheduleMonthday = &v + } + t.Reboot = reboot.Valid && reboot.Bool + t.HealthCheck = healthCheck.Valid && healthCheck.Bool + t.HealthCheckTimeout = 600 + if healthCheckTimeout.Valid { + t.HealthCheckTimeout = int(healthCheckTimeout.Int32) + } + t.Active = !active.Valid || active.Bool + t.RunCount = int(runCount.Int32) + t.ScheduledAt = scheduledAt.Format(time.RFC3339) t.CreatedAt = createdAt.Format(time.RFC3339) if startedAt.Valid { @@ -87,6 +182,14 @@ func (d *Database) GetTask(id int) (*models.Task, error) { s := finishedAt.Time.Format(time.RFC3339) t.FinishedAt = &s } + if lastRun.Valid { + s := lastRun.Time.Format(time.RFC3339) + t.LastRun = &s + } + if nextRun.Valid { + s := nextRun.Time.Format(time.RFC3339) + t.NextRun = &s + } if resultJSON.Valid { var r interface{} json.Unmarshal([]byte(resultJSON.String), &r) @@ -105,6 +208,29 @@ func (d *Database) GetTask(id int) (*models.Task, error) { return &t, nil } +const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), + t.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, + 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` + +const taskFromJoin = `FROM tasks t + LEFT JOIN agents a ON a.id = t.agent_id + LEFT JOIN customers c ON c.id = a.customer_id` + +func (d *Database) GetTask(id int) (*models.Task, error) { + row := d.db.QueryRow(fmt.Sprintf("SELECT %s %s WHERE t.id = $1", taskSelectCols, taskFromJoin), id) + t, err := scanTask(row.Scan) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return t, nil +} + func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ([]models.Task, int, error) { if limit <= 0 { limit = 50 @@ -135,24 +261,14 @@ func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ( 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) + query := fmt.Sprintf("SELECT %s %s %s ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d", + taskSelectCols, taskFromJoin, whereClause, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := d.db.Query(query, args...) @@ -163,50 +279,11 @@ func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ( 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 { + t, err := scanTask(rows.Scan) + if 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) + tasks = append(tasks, *t) } if tasks == nil { tasks = []models.Task{} @@ -245,8 +322,44 @@ func (d *Database) UpdateTaskStatus(id int, status string, result interface{}) e } } +// CompleteRecurringTask handles post-execution for recurring tasks. +func (d *Database) CompleteRecurringTask(id int, scheduleType, scheduleTime string, weekday, monthday *int, success bool, 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() + + if scheduleType == "once" { + status := "completed" + if !success { + status = "failed" + } + _, err := d.db.Exec( + `UPDATE tasks SET status = $1, finished_at = $2, result = $3, active = FALSE, + last_run = $2, run_count = run_count + 1 WHERE id = $4`, + status, now, resultJSON, id, + ) + return err + } + + // Recurring: reset to scheduled, compute next_run + nextRun := CalculateNextRun(scheduleType, scheduleTime, weekday, monthday, now) + _, err := d.db.Exec( + `UPDATE tasks SET status = 'scheduled', finished_at = $1, result = $2, + last_run = $1, next_run = $3, run_count = run_count + 1, + started_at = NULL WHERE id = $4`, + now, resultJSON, nextRun, 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] } @@ -258,15 +371,11 @@ func (d *Database) SetTaskWebhookResult(id int, sent bool, response string) erro } 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 - `) + query := fmt.Sprintf(`SELECT %s %s + WHERE t.active = TRUE AND t.next_run IS NOT NULL AND t.next_run <= NOW() AND t.status != 'running' + ORDER BY t.next_run ASC`, taskSelectCols, taskFromJoin) + + rows, err := d.db.Query(query) if err != nil { return nil, err } @@ -274,29 +383,23 @@ func (d *Database) GetDueTasks() ([]models.Task, error) { 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 { + t, err := scanTask(rows.Scan) + 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 webhookURL.Valid { - t.WebhookURL = webhookURL.String - } - tasks = append(tasks, t) + tasks = append(tasks, *t) } return tasks, rows.Err() } +func (d *Database) ToggleTask(id int) (*models.Task, error) { + _, err := d.db.Exec("UPDATE tasks SET active = NOT active WHERE id = $1", id) + if err != nil { + return nil, err + } + return d.GetTask(id) +} + func (d *Database) CancelTask(id int) error { res, err := d.db.Exec( "UPDATE tasks SET status = 'cancelled' WHERE id = $1 AND status = 'scheduled'", diff --git a/backend/models/task.go b/backend/models/task.go index c645f01..d26a549 100644 --- a/backend/models/task.go +++ b/backend/models/task.go @@ -1,19 +1,31 @@ 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"` + ID int `json:"id"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` + CustomerName string `json:"customer_name,omitempty"` + Name string `json:"name"` + Action string `json:"action"` + Status string `json:"status"` + ScheduleType string `json:"schedule_type"` + ScheduleTime string `json:"schedule_time"` + ScheduleWeekday *int `json:"schedule_weekday,omitempty"` + ScheduleMonthday *int `json:"schedule_monthday,omitempty"` + ScheduledAt string `json:"scheduled_at"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + Result interface{} `json:"result,omitempty"` + Reboot bool `json:"reboot"` + HealthCheck bool `json:"health_check"` + HealthCheckTimeout int `json:"health_check_timeout"` + WebhookURL string `json:"webhook_url,omitempty"` + WebhookSent bool `json:"webhook_sent"` + WebhookResponse string `json:"webhook_response,omitempty"` + Active bool `json:"active"` + LastRun *string `json:"last_run,omitempty"` + NextRun *string `json:"next_run,omitempty"` + RunCount int `json:"run_count"` + 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 6c99959..b8b12df 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -315,6 +315,10 @@ class ApiClient { return this.del(`/api/v1/tasks/${id}`) } + toggleTask(id) { + return this.put(`/api/v1/tasks/${id}/toggle`) + } + // Installer getInstallerInfo() { return this.get('/api/v1/installer') diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index cbb608e..ddd4f74 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -6,7 +6,7 @@ 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, Ban, - FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, + FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, } from 'lucide-react' // Two-level navigation structure (DATAZONE style) @@ -118,7 +118,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { if (tab === 'certs') return if (tab === 'updates') return 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 @@ -1616,222 +1616,224 @@ function SecurityTab({ sys }) { ) } -function TasksTab({ agentId }) { +function TasksTab({ agentId, agentName }) { 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 [showWebhook, setShowWebhook] = useState(false) + + const defaultForm = { + name: '', action: '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, + webhook_url: '', active: true, + } + const [form, setForm] = useState(defaultForm) const loadTasks = () => { api.getTasks({ agent_id: agentId, limit: 50 }) - .then(resp => { - setTasks(resp.data || []) - setTotal(resp.total || 0) - }) + .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]) + useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId]) const handleCreate = async () => { - setCreating(true) - setError('') + setCreating(true); setError('') try { - const data = { - agent_id: agentId, - action: newTask.action, + const data = { agent_id: agentId, name: form.name, action: form.action, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active } + if (form.schedule_type === 'once' && form.scheduled_at) { + data.scheduled_at = new Date(form.scheduled_at).toISOString() } - if (newTask.scheduled_at) { - // Convert datetime-local to RFC3339 - const d = new Date(newTask.scheduled_at) - data.scheduled_at = d.toISOString() + if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday) + if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday) + if (form.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 (newTask.webhook_url) data.webhook_url = newTask.webhook_url + if (form.webhook_url) data.webhook_url = form.webhook_url await api.createTask(data) - setShowDialog(false) - setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' }) - loadTasks() - } catch (e) { - setError(e.message) - } finally { - setCreating(false) - } + setShowDialog(false); setForm(defaultForm); setShowWebhook(false); 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 handleToggle = async (id) => { + try { await api.toggleTask(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) - } + if (!confirm('Job wirklich loeschen?')) return + try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) } + } + const handleRunNow = async (id) => { + // Trigger by creating a one-off copy? For now just toggle to trigger scheduler + // Simple approach: we don't have a "run now" endpoint, so we skip for now } - 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 weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] + + const fmtInterval = (t) => { + if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}` + if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}` + if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}` + return 'Einmalig' } const fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—' + const inputCls = 'w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500' + const labelCls = 'block text-xs text-gray-400 mb-1' + return (
-
{total} Aufgabe(n)
-
- {/* Create Dialog */} + {/* Modal */} {showDialog && ( -
-
-
- - +
setShowDialog(false)}> +
e.stopPropagation()}> +
+

Neuer Job{agentName ? ` — ${agentName}` : ''}

+
-
- - 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" - /> +
+
+ + setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} /> +
+
+
+ + +
+
+ + setForm({...form, schedule_time: e.target.value})} className={inputCls} /> +
+
+ {form.schedule_type === 'once' && ( +
+ + setForm({...form, scheduled_at: e.target.value})} className={inputCls} /> +
+ )} + {form.schedule_type === 'weekly' && ( +
+ + +
+ )} + {form.schedule_type === 'monthly' && ( +
+ + setForm({...form, schedule_monthday: e.target.value})} className={inputCls} /> +
+ )} +
+ +
+ {['backup', 'update'].map(a => ( + + ))} +
+
+ {form.action === 'update' && ( +
+ + + {form.health_check && ( +
+ + setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[200px]'} /> +
+ )} +
+ )} +
+ + {showWebhook && ( + setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls + ' mt-1'} /> + )} +
+ + {error &&
{error}
} +
+
+ +
-
-
- - 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 */} + {/* Table */} {loading ? (
Laden...
) : tasks.length === 0 ? ( -
- Keine Aufgaben vorhanden -
+
Keine Jobs vorhanden
) : (
- + + - - - - - + + + {tasks.map(t => ( - - - - - - - - + + + + + +
IDStatusName AktionStatusGeplantGestartetAbgeschlossenWebhookIntervallNächster LaufLetzter Lauf 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 === 'completed' || t.status === 'failed' ? ( - - ) : ( - - ) - ) : null} +
+ {t.name || `Job #${t.id}`}{t.action}{fmtInterval(t)}{fmtDate(t.next_run)}{fmtDate(t.last_run)}
- {t.status === 'scheduled' && ( - - )} - +