Feature: Recurring jobs with professional scheduling dialog
This commit is contained in:
parent
f4a28463b7
commit
721fd5008f
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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'",
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user