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("POST /api/v1/tasks", h.createTask(hub))
mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask) mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask)
mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask) mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask)
mux.HandleFunc("PUT /api/v1/tasks/{id}/toggle", h.toggleTask)
// WebSocket und Tunnel-Routes // WebSocket und Tunnel-Routes
h.setupTunnelRoutes(mux, hub) h.setupTunnelRoutes(mux, hub)

View File

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/cynfo/rmm-backend/db" "github.com/cynfo/rmm-backend/db"
"github.com/cynfo/rmm-backend/models"
"github.com/cynfo/rmm-backend/ws" "github.com/cynfo/rmm-backend/ws"
) )
@ -44,46 +45,46 @@ func (s *TaskScheduler) processDueTasks() {
} }
for _, task := range tasks { 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) { func (s *TaskScheduler) executeTask(task models.Task) {
log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", taskID, action, agentID) log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", task.ID, task.Action, task.AgentID)
// Set status to running // Set status to running
s.db.UpdateTaskStatus(taskID, "running", nil) s.db.UpdateTaskStatus(task.ID, "running", nil)
var result interface{} var result interface{}
var taskErr error var taskErr error
switch action { switch task.Action {
case "backup": case "backup":
result, taskErr = s.executeBackup(agentID) result, taskErr = s.executeBackup(task.AgentID)
case "update": case "update":
result, taskErr = s.executeUpdate(agentID) result, taskErr = s.executeUpdate(task.AgentID, task.Reboot)
default: default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", action) taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action)
} }
// Update status success := taskErr == nil
status := "completed" if !success {
if taskErr != nil {
status = "failed"
result = map[string]string{"error": taskErr.Error()} 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 { } 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 // Re-read task for webhook payload
task, _ := s.db.GetTask(taskID) fullTask, _ := s.db.GetTask(task.ID)
// Send webhook if configured // Send webhook if configured
if webhookURL != "" && task != nil { if task.WebhookURL != "" && fullTask != nil {
s.sendWebhook(taskID, webhookURL, task) 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") return resp, fmt.Errorf("ungueltiges Response-Format")
} }
// Save backup to DB (same logic as triggerBackup handler)
configB64, _ := data["config"].(string) configB64, _ := data["config"].(string)
hash, _ := data["hash"].(string) hash, _ := data["hash"].(string)
sizeFloat, _ := data["size"].(float64) 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) { if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden") return nil, fmt.Errorf("Agent nicht verbunden")
} }
@ -190,7 +190,7 @@ func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) {
"type": "command", "type": "command",
"id": updateCmdID, "id": updateCmdID,
"command": "update", "command": "update",
"params": map[string]interface{}{"reboot": false}, "params": map[string]interface{}{"reboot": reboot},
} }
updateCh := s.hub.RegisterResponseWaiter(updateCmdID) 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 resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg)
} }
return map[string]interface{}{ return map[string]interface{}{
"message": "Update ausgefuehrt", "message": "Update ausgefuehrt",
"check_result": checkResult, "check_result": checkResult,
"update_result": resp, "update_result": resp,
"reboot": reboot,
}, nil }, nil
case <-time.After(900 * time.Second): case <-time.After(900 * time.Second):
return nil, fmt.Errorf("Timeout beim Update") 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 { func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
Action string `json:"action"` Name string `json:"name"`
ScheduledAt string `json:"scheduled_at"` Action string `json:"action"`
WebhookURL string `json:"webhook_url"` 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 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
@ -83,26 +92,33 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
return return
} }
// Validate action
if req.Action != "backup" && req.Action != "update" { if req.Action != "backup" && req.Action != "update" {
writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update)") writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update)")
return return
} }
// Check agent exists
agent, _, err := h.db.GetAgent(req.AgentID) agent, _, err := h.db.GetAgent(req.AgentID)
if err != nil || agent == nil { if err != nil || agent == nil {
writeError(w, http.StatusNotFound, "Agent nicht gefunden") writeError(w, http.StatusNotFound, "Agent nicht gefunden")
return return
} }
// Get user from context
createdBy := "api-key" createdBy := "api-key"
if claims, ok := r.Context().Value(UserContextKey).(*JWTClaims); ok { if claims, ok := r.Context().Value(UserContextKey).(*JWTClaims); ok {
createdBy = claims.Username 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 { if err != nil {
log.Printf("Task erstellen fehlgeschlagen: %v", err) log.Printf("Task erstellen fehlgeschlagen: %v", err)
writeError(w, http.StatusInternalServerError, "Task erstellen fehlgeschlagen: "+err.Error()) 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) 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 once and scheduled_at is in the past or now, scheduler picks it up within 30s
if req.ScheduledAt == "" { if req.ScheduledAt != "" && req.ScheduleType == "once" {
// Already scheduled for now, scheduler will pick it up within 30s if schedTime, err := time.Parse(time.RFC3339, req.ScheduledAt); err == nil && !schedTime.After(time.Now()) {
} else { // scheduler will pick it up
schedTime, err := time.Parse(time.RFC3339, req.ScheduledAt) _ = schedTime
if err == nil && !schedTime.After(time.Now()) {
// In the past — scheduler picks it up
} }
} }
@ -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 // POST /api/v1/tasks/{id}/cancel
func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) { func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") 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)`) 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) // Retention Policy (90 Tage)
d.setupRetention() d.setupRetention()

View File

@ -10,73 +10,168 @@ import (
"github.com/cynfo/rmm-backend/models" "github.com/cynfo/rmm-backend/models"
) )
func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string) (*models.Task, error) { // CalculateNextRun computes the next run time based on schedule parameters.
// Validate action 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} validActions := map[string]bool{"backup": true, "update": true}
if !validActions[action] { if !validActions[action] {
return nil, fmt.Errorf("ungueltige Aktion: %s", action) return nil, fmt.Errorf("ungueltige Aktion: %s", action)
} }
var schedTime time.Time if scheduleType == "" {
var err error scheduleType = "once"
if scheduledAt == "" { }
schedTime = time.Now() if scheduleTime == "" {
} else { scheduleTime = "02:00"
schedTime, err = time.Parse(time.RFC3339, scheduledAt) }
if err != nil { if healthCheckTimeout <= 0 {
return nil, fmt.Errorf("ungueltiges Datum: %w", err) healthCheckTimeout = 600
}
} }
var task models.Task now := time.Now()
err = d.db.QueryRow(` var schedTime time.Time
INSERT INTO tasks (agent_id, action, scheduled_at, webhook_url, created_by)
VALUES ($1, $2, $3, $4, $5) if scheduleType == "once" {
RETURNING id, agent_id, action, status, scheduled_at, webhook_url, COALESCE(created_by, ''), created_at if scheduledAt == "" {
`, agentID, action, schedTime, webhookURL, createdBy).Scan( schedTime = now
&task.ID, &task.AgentID, &task.Action, &task.Status, } else {
&schedTime, &task.WebhookURL, &task.CreatedBy, &schedTime, 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 { if err != nil {
return nil, fmt.Errorf("Task erstellen: %w", err) return nil, fmt.Errorf("Task erstellen: %w", err)
} }
// Re-read cleanly return d.GetTask(taskID)
return d.GetTask(task.ID)
} }
func (d *Database) GetTask(id int) (*models.Task, error) { func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
var t models.Task var t models.Task
var scheduledAt, createdAt time.Time var scheduledAt, createdAt time.Time
var startedAt, finishedAt sql.NullTime var startedAt, finishedAt, lastRun, nextRun sql.NullTime
var resultJSON sql.NullString var resultJSON sql.NullString
var webhookURL, webhookResponse, createdBy sql.NullString var webhookURL, webhookResponse, createdBy sql.NullString
var agentName, customerName 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(` err := scan(
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.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, &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
&createdBy, &createdAt, &createdBy, &createdAt,
&scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday,
&reboot, &healthCheck, &healthCheckTimeout, &active, &lastRun, &nextRun, &runCount,
) )
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
t.AgentName = agentName.String t.AgentName = agentName.String
t.CustomerName = customerName.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.ScheduledAt = scheduledAt.Format(time.RFC3339)
t.CreatedAt = createdAt.Format(time.RFC3339) t.CreatedAt = createdAt.Format(time.RFC3339)
if startedAt.Valid { if startedAt.Valid {
@ -87,6 +182,14 @@ func (d *Database) GetTask(id int) (*models.Task, error) {
s := finishedAt.Time.Format(time.RFC3339) s := finishedAt.Time.Format(time.RFC3339)
t.FinishedAt = &s 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 { if resultJSON.Valid {
var r interface{} var r interface{}
json.Unmarshal([]byte(resultJSON.String), &r) json.Unmarshal([]byte(resultJSON.String), &r)
@ -105,6 +208,29 @@ func (d *Database) GetTask(id int) (*models.Task, error) {
return &t, nil 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) { func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ([]models.Task, int, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
@ -135,24 +261,14 @@ func (d *Database) GetTasks(limit, offset int, agentID, status, action string) (
whereClause = "WHERE " + strings.Join(where, " AND ") whereClause = "WHERE " + strings.Join(where, " AND ")
} }
// Count
var total int var total int
countQuery := "SELECT COUNT(*) FROM tasks t " + whereClause countQuery := "SELECT COUNT(*) FROM tasks t " + whereClause
if err := d.db.QueryRow(countQuery, args...).Scan(&total); err != nil { if err := d.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
// Data query := fmt.Sprintf("SELECT %s %s %s ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d",
query := fmt.Sprintf(` taskSelectCols, taskFromJoin, whereClause, argIdx, argIdx+1)
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) args = append(args, limit, offset)
rows, err := d.db.Query(query, args...) 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 var tasks []models.Task
for rows.Next() { for rows.Next() {
var t models.Task t, err := scanTask(rows.Scan)
var scheduledAt, createdAt time.Time if err != nil {
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 return nil, 0, err
} }
tasks = append(tasks, *t)
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 { if tasks == nil {
tasks = []models.Task{} 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 { func (d *Database) SetTaskWebhookResult(id int, sent bool, response string) error {
// Truncate response to 500 chars
if len(response) > 500 { if len(response) > 500 {
response = 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) { func (d *Database) GetDueTasks() ([]models.Task, error) {
rows, err := d.db.Query(` query := fmt.Sprintf(`SELECT %s %s
SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), WHERE t.active = TRUE AND t.next_run IS NOT NULL AND t.next_run <= NOW() AND t.status != 'running'
t.action, t.status, t.scheduled_at, t.webhook_url, t.created_at ORDER BY t.next_run ASC`, taskSelectCols, taskFromJoin)
FROM tasks t
LEFT JOIN agents a ON a.id = t.agent_id rows, err := d.db.Query(query)
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 { if err != nil {
return nil, err return nil, err
} }
@ -274,29 +383,23 @@ func (d *Database) GetDueTasks() ([]models.Task, error) {
var tasks []models.Task var tasks []models.Task
for rows.Next() { for rows.Next() {
var t models.Task t, err := scanTask(rows.Scan)
var scheduledAt, createdAt time.Time if err != nil {
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 return nil, err
} }
t.AgentName = agentName.String tasks = append(tasks, *t)
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() 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 { func (d *Database) CancelTask(id int) error {
res, err := d.db.Exec( res, err := d.db.Exec(
"UPDATE tasks SET status = 'cancelled' WHERE id = $1 AND status = 'scheduled'", "UPDATE tasks SET status = 'cancelled' WHERE id = $1 AND status = 'scheduled'",

View File

@ -1,19 +1,31 @@
package models package models
type Task struct { type Task struct {
ID int `json:"id"` ID int `json:"id"`
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"` AgentName string `json:"agent_name,omitempty"`
CustomerName string `json:"customer_name,omitempty"` CustomerName string `json:"customer_name,omitempty"`
Action string `json:"action"` Name string `json:"name"`
Status string `json:"status"` Action string `json:"action"`
ScheduledAt string `json:"scheduled_at"` Status string `json:"status"`
StartedAt *string `json:"started_at,omitempty"` ScheduleType string `json:"schedule_type"`
FinishedAt *string `json:"finished_at,omitempty"` ScheduleTime string `json:"schedule_time"`
Result interface{} `json:"result,omitempty"` ScheduleWeekday *int `json:"schedule_weekday,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"` ScheduleMonthday *int `json:"schedule_monthday,omitempty"`
WebhookSent bool `json:"webhook_sent"` ScheduledAt string `json:"scheduled_at"`
WebhookResponse string `json:"webhook_response,omitempty"` StartedAt *string `json:"started_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"` FinishedAt *string `json:"finished_at,omitempty"`
CreatedAt string `json:"created_at"` 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}`) return this.del(`/api/v1/tasks/${id}`)
} }
toggleTask(id) {
return this.put(`/api/v1/tasks/${id}/toggle`)
}
// Installer // Installer
getInstallerInfo() { getInstallerInfo() {
return this.get('/api/v1/installer') return this.get('/api/v1/installer')

View File

@ -6,7 +6,7 @@ import { useSettingsStore } from '../stores/settings'
import { import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban, 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' } from 'lucide-react'
// Two-level navigation structure (DATAZONE style) // 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 === 'certs') return <CertsTab sys={sys} />
if (tab === 'updates') return <UpdatesTab agentId={agentId} /> if (tab === 'updates') return <UpdatesTab agentId={agentId} />
if (tab === 'security') return <SecurityTab sys={sys} /> 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 === '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 === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
if (tab === 'haproxy') return <HAProxyTab sys={sys} /> 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 [tasks, setTasks] = useState([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showDialog, setShowDialog] = useState(false) const [showDialog, setShowDialog] = useState(false)
const [newTask, setNewTask] = useState({ action: 'backup', scheduled_at: '', webhook_url: '' })
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [error, setError] = useState('') 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 = () => { const loadTasks = () => {
api.getTasks({ agent_id: agentId, limit: 50 }) api.getTasks({ agent_id: agentId, limit: 50 })
.then(resp => { .then(resp => { setTasks(resp.data || []); setTotal(resp.total || 0) })
setTasks(resp.data || [])
setTotal(resp.total || 0)
})
.catch(() => setTasks([])) .catch(() => setTasks([]))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
useEffect(() => { loadTasks() }, [agentId]) useEffect(() => { loadTasks() }, [agentId])
useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId])
// Auto-refresh every 15s
useEffect(() => {
const iv = setInterval(loadTasks, 15000)
return () => clearInterval(iv)
}, [agentId])
const handleCreate = async () => { const handleCreate = async () => {
setCreating(true) setCreating(true); setError('')
setError('')
try { try {
const data = { const data = { agent_id: agentId, name: form.name, action: form.action, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active }
agent_id: agentId, if (form.schedule_type === 'once' && form.scheduled_at) {
action: newTask.action, data.scheduled_at = new Date(form.scheduled_at).toISOString()
} }
if (newTask.scheduled_at) { if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
// Convert datetime-local to RFC3339 if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
const d = new Date(newTask.scheduled_at) if (form.action === 'update') {
data.scheduled_at = d.toISOString() 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) await api.createTask(data)
setShowDialog(false) setShowDialog(false); setForm(defaultForm); setShowWebhook(false); loadTasks()
setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' }) } catch (e) { setError(e.message) } finally { setCreating(false) }
loadTasks()
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
} }
const handleCancel = async (id) => { const handleToggle = async (id) => {
if (!confirm('Task wirklich abbrechen?')) return try { await api.toggleTask(id); loadTasks() } catch (e) { setError(e.message) }
try {
await api.cancelTask(id)
loadTasks()
} catch (e) {
setError(e.message)
}
} }
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!confirm('Task wirklich loeschen?')) return if (!confirm('Job wirklich loeschen?')) return
try { try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) }
await api.deleteTask(id) }
loadTasks() const handleRunNow = async (id) => {
} catch (e) { // Trigger by creating a one-off copy? For now just toggle to trigger scheduler
setError(e.message) // Simple approach: we don't have a "run now" endpoint, so we skip for now
}
} }
const statusBadge = (status) => { const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
const styles = {
scheduled: 'bg-gray-700 text-gray-300', const fmtInterval = (t) => {
running: 'bg-blue-900/50 text-blue-400 border border-blue-700/50', if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}`
completed: 'bg-green-900/40 text-green-400 border border-green-700/50', if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}`
failed: 'bg-red-900/40 text-red-400 border border-red-700/50', if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}`
cancelled: 'bg-yellow-900/40 text-yellow-400 border border-yellow-700/50', return 'Einmalig'
}
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 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 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-gray-400">{total} Aufgabe(n)</div> <div className="text-sm text-gray-400">{total} Job(s)</div>
<button <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">
onClick={() => setShowDialog(!showDialog)} <Plus className="w-4 h-4" /> Neuer Job
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
</button> </button>
</div> </div>
{/* Create Dialog */} {/* Modal */}
{showDialog && ( {showDialog && (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3"> <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
<div className="grid grid-cols-2 gap-3"> <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> <div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<label className="block text-xs text-gray-400 mb-1">Aktion</label> <h3 className="text-white font-semibold">Neuer Job{agentName ? `${agentName}` : ''}</h3>
<select <button onClick={() => setShowDialog(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
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> </div>
<div> <div className="px-5 py-4 space-y-4">
<label className="block text-xs text-gray-400 mb-1">Zeitpunkt (leer = sofort)</label> <div>
<input <label className={labelCls}>Name *</label>
type="datetime-local" <input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
value={newTask.scheduled_at} </div>
onChange={e => setNewTask({ ...newTask, scheduled_at: e.target.value })} <div className="grid grid-cols-2 gap-3">
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>
/> <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>
<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>
</div> </div>
)} )}
{/* Task Table */} {/* Table */}
{loading ? ( {loading ? (
<div className="text-gray-500">Laden...</div> <div className="text-gray-500">Laden...</div>
) : tasks.length === 0 ? ( ) : 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"> <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>
Keine Aufgaben vorhanden
</div>
) : ( ) : (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden"> <div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700"> <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">Aktion</th>
<th className="px-3 py-2">Status</th> <th className="px-3 py-2">Intervall</th>
<th className="px-3 py-2">Geplant</th> <th className="px-3 py-2">Nächster Lauf</th>
<th className="px-3 py-2">Gestartet</th> <th className="px-3 py-2">Letzter Lauf</th>
<th className="px-3 py-2">Abgeschlossen</th>
<th className="px-3 py-2">Webhook</th>
<th className="px-3 py-2 text-right">Aktionen</th> <th className="px-3 py-2 text-right">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700/50"> <tbody className="divide-y divide-gray-700/50">
{tasks.map(t => ( {tasks.map(t => (
<tr key={t.id}> <tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
<td className="px-3 py-2 text-white">{t.id}</td> <td className="px-3 py-2">
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td> <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 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}
</td> </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"> <td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{t.status === 'scheduled' && ( <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'}>
<button <Power className="w-4 h-4" />
onClick={() => handleCancel(t.id)} </button>
className="text-yellow-400 hover:text-yellow-300 p-1 rounded hover:bg-yellow-900/30 transition-colors" <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">
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"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>