Feature: Recurring jobs with professional scheduling dialog

This commit is contained in:
cynfo3000 2026-03-05 09:39:41 +01:00
parent f4a28463b7
commit 721fd5008f
8 changed files with 505 additions and 330 deletions

View File

@ -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)

View File

@ -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")

View File

@ -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")

View File

@ -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()

View File

@ -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'",

View File

@ -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"`
}

View File

@ -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')

View File

@ -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 <CertsTab sys={sys} />
if (tab === 'updates') return <UpdatesTab agentId={agentId} />
if (tab === 'security') return <SecurityTab sys={sys} />
if (tab === 'tasks') return <TasksTab agentId={agentId} />
if (tab === 'tasks') return <TasksTab agentId={agentId} agentName={agent?.name || ''} />
if (tab === 'backups') return platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
if (tab === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
if (tab === 'haproxy') return <HAProxyTab sys={sys} />
@ -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 (
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded ${styles[status] || 'bg-gray-700 text-gray-400'}`}>
{status === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />}
{labels[status] || status}
</span>
)
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">{total} Aufgabe(n)</div>
<button
onClick={() => setShowDialog(!showDialog)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
>
<Plus className="w-4 h-4" />
Aufgabe planen
<div className="text-sm text-gray-400">{total} Job(s)</div>
<button onClick={() => setShowDialog(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors">
<Plus className="w-4 h-4" /> Neuer Job
</button>
</div>
{/* Create Dialog */}
{/* Modal */}
{showDialog && (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Aktion</label>
<select
value={newTask.action}
onChange={e => setNewTask({ ...newTask, action: 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"
>
<option value="backup">Backup</option>
<option value="update">Update</option>
</select>
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<h3 className="text-white font-semibold">Neuer Job{agentName ? `${agentName}` : ''}</h3>
<button onClick={() => setShowDialog(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Zeitpunkt (leer = sofort)</label>
<input
type="datetime-local"
value={newTask.scheduled_at}
onChange={e => 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"
/>
<div className="px-5 py-4 space-y-4">
<div>
<label className={labelCls}>Name *</label>
<input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Intervall *</label>
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls}>
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
<div>
<label className={labelCls}>Uhrzeit *</label>
<input type="time" value={form.schedule_time} onChange={e => setForm({...form, schedule_time: e.target.value})} className={inputCls} />
</div>
</div>
{form.schedule_type === 'once' && (
<div>
<label className={labelCls}>Datum</label>
<input type="datetime-local" value={form.scheduled_at} onChange={e => setForm({...form, scheduled_at: e.target.value})} className={inputCls} />
</div>
)}
{form.schedule_type === 'weekly' && (
<div>
<label className={labelCls}>Wochentag</label>
<select value={form.schedule_weekday} onChange={e => setForm({...form, schedule_weekday: e.target.value})} className={inputCls}>
{weekdays.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
)}
{form.schedule_type === 'monthly' && (
<div>
<label className={labelCls}>Tag im Monat</label>
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
</div>
)}
<div>
<label className={labelCls}>Aktion *</label>
<div className="flex gap-2">
{['backup', 'update'].map(a => (
<button key={a} onClick={() => setForm({...form, action: a})}
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
{a === 'backup' ? 'Backup' : 'Update'}
</button>
))}
</div>
</div>
{form.action === 'update' && (
<div className="space-y-2 pl-1">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
Reboot nach Update
</label>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.health_check} onChange={e => setForm({...form, health_check: e.target.checked})} className="accent-orange-500" />
Health-Check nach Update
</label>
{form.health_check && (
<div className="pl-6">
<label className={labelCls}>Health-Check Timeout (Sekunden)</label>
<input type="number" value={form.health_check_timeout} onChange={e => setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[200px]'} />
</div>
)}
</div>
)}
<div>
<button onClick={() => setShowWebhook(!showWebhook)} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-300">
{showWebhook ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Webhook-URL (optional)
</button>
{showWebhook && (
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls + ' mt-1'} />
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.active} onChange={e => setForm({...form, active: e.target.checked})} className="accent-orange-500" />
Job ist aktiv
</label>
{error && <div className="text-sm text-red-400">{error}</div>}
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
<button onClick={() => { setShowDialog(false); setError('') }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors">Abbrechen</button>
<button onClick={handleCreate} disabled={creating || !form.name} className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
{creating ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Webhook-URL (optional)</label>
<input
type="text"
value={newTask.webhook_url}
onChange={e => 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"
/>
</div>
{error && <div className="text-sm text-red-400">{error}</div>}
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={creating}
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 rounded text-sm text-white transition-colors"
>
{creating ? 'Erstelle...' : 'Erstellen'}
</button>
<button
onClick={() => { setShowDialog(false); setError('') }}
className="px-4 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
>
Abbrechen
</button>
</div>
</div>
)}
{/* Task Table */}
{/* Table */}
{loading ? (
<div className="text-gray-500">Laden...</div>
) : tasks.length === 0 ? (
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">
Keine Aufgaben vorhanden
</div>
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">Keine Jobs vorhanden</div>
) : (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2 w-8">Status</th>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Aktion</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Geplant</th>
<th className="px-3 py-2">Gestartet</th>
<th className="px-3 py-2">Abgeschlossen</th>
<th className="px-3 py-2">Webhook</th>
<th className="px-3 py-2">Intervall</th>
<th className="px-3 py-2">Nächster Lauf</th>
<th className="px-3 py-2">Letzter Lauf</th>
<th className="px-3 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{tasks.map(t => (
<tr key={t.id}>
<td className="px-3 py-2 text-white">{t.id}</td>
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td>
<td className="px-3 py-2">{statusBadge(t.status)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.scheduled_at)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.started_at)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.finished_at)}</td>
<td className="px-3 py-2 text-center">
{t.webhook_url ? (
t.webhook_sent ? (
<Check className="w-4 h-4 text-green-400 inline" />
) : t.status === 'completed' || t.status === 'failed' ? (
<X className="w-4 h-4 text-red-400 inline" />
) : (
<Clock className="w-3.5 h-3.5 text-gray-500 inline" />
)
) : null}
<tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
<td className="px-3 py-2">
<span className={`inline-block w-2.5 h-2.5 rounded-full ${t.active ? 'bg-green-500' : 'bg-gray-500'}`} title={t.active ? 'Aktiv' : 'Inaktiv'} />
</td>
<td className="px-3 py-2 text-white">{t.name || `Job #${t.id}`}</td>
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.next_run)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.last_run)}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{t.status === 'scheduled' && (
<button
onClick={() => handleCancel(t.id)}
className="text-yellow-400 hover:text-yellow-300 p-1 rounded hover:bg-yellow-900/30 transition-colors"
title="Abbrechen"
>
<Ban className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(t.id)}
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
title="Loeschen"
>
<button onClick={() => handleToggle(t.id)} className={`p-1 rounded transition-colors ${t.active ? 'text-green-400 hover:text-green-300 hover:bg-green-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-700'}`} title={t.active ? 'Deaktivieren' : 'Aktivieren'}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(t.id)} className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors" title="Löschen">
<Trash2 className="w-4 h-4" />
</button>
</div>