Feature: Task Scheduler - planned tasks with webhook support
This commit is contained in:
parent
e1a3246075
commit
7908ae26a0
@ -80,6 +80,13 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("POST /api/v1/installer/upload", h.uploadInstaller)
|
mux.HandleFunc("POST /api/v1/installer/upload", h.uploadInstaller)
|
||||||
mux.HandleFunc("GET /api/v1/installer/download", h.downloadInstaller)
|
mux.HandleFunc("GET /api/v1/installer/download", h.downloadInstaller)
|
||||||
|
|
||||||
|
// Task-Routes
|
||||||
|
mux.HandleFunc("GET /api/v1/tasks", h.listTasks)
|
||||||
|
mux.HandleFunc("GET /api/v1/tasks/{id}", h.getTask)
|
||||||
|
mux.HandleFunc("POST /api/v1/tasks", h.createTask(hub))
|
||||||
|
mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask)
|
||||||
|
mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask)
|
||||||
|
|
||||||
// WebSocket und Tunnel-Routes
|
// WebSocket und Tunnel-Routes
|
||||||
h.setupTunnelRoutes(mux, hub)
|
h.setupTunnelRoutes(mux, hub)
|
||||||
}
|
}
|
||||||
|
|||||||
244
backend/api/scheduler.go
Normal file
244
backend/api/scheduler.go
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/db"
|
||||||
|
"github.com/cynfo/rmm-backend/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskScheduler struct {
|
||||||
|
db *db.Database
|
||||||
|
hub *ws.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskScheduler(database *db.Database, hub *ws.Hub) *TaskScheduler {
|
||||||
|
return &TaskScheduler{db: database, hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) Run() {
|
||||||
|
log.Println("Task-Scheduler gestartet (30s Intervall)")
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run once immediately
|
||||||
|
s.processDueTasks()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.processDueTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) processDueTasks() {
|
||||||
|
tasks, err := s.db.GetDueTasks()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Scheduler: Fehler beim Laden faelliger Tasks: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
go s.executeTask(task.ID, task.AgentID, task.Action, task.WebhookURL, task.AgentName, task.CustomerName, task.ScheduledAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) executeTask(taskID int, agentID, action, webhookURL, agentName, customerName, scheduledAt string) {
|
||||||
|
log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", taskID, action, agentID)
|
||||||
|
|
||||||
|
// Set status to running
|
||||||
|
s.db.UpdateTaskStatus(taskID, "running", nil)
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
var taskErr error
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "backup":
|
||||||
|
result, taskErr = s.executeBackup(agentID)
|
||||||
|
case "update":
|
||||||
|
result, taskErr = s.executeUpdate(agentID)
|
||||||
|
default:
|
||||||
|
taskErr = fmt.Errorf("unbekannte Aktion: %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
status := "completed"
|
||||||
|
if taskErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
result = map[string]string{"error": taskErr.Error()}
|
||||||
|
log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", taskID, taskErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db.UpdateTaskStatus(taskID, status, result)
|
||||||
|
|
||||||
|
// Re-read task for webhook payload
|
||||||
|
task, _ := s.db.GetTask(taskID)
|
||||||
|
|
||||||
|
// Send webhook if configured
|
||||||
|
if webhookURL != "" && task != nil {
|
||||||
|
s.sendWebhook(taskID, webhookURL, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) {
|
||||||
|
if !s.hub.IsAgentConnected(agentID) {
|
||||||
|
return nil, fmt.Errorf("Agent nicht verbunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdID := generateID()
|
||||||
|
cmd := map[string]interface{}{
|
||||||
|
"type": "command",
|
||||||
|
"id": cmdID,
|
||||||
|
"command": "backup",
|
||||||
|
"params": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
responseCh := s.hub.RegisterResponseWaiter(cmdID)
|
||||||
|
defer s.hub.UnregisterResponseWaiter(cmdID)
|
||||||
|
|
||||||
|
cmdJSON, _ := json.Marshal(cmd)
|
||||||
|
if err := s.hub.SendToAgent(agentID, cmdJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("Command senden fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-responseCh:
|
||||||
|
respStatus, _ := resp["status"].(string)
|
||||||
|
if respStatus != "ok" {
|
||||||
|
errMsg, _ := resp["error"].(string)
|
||||||
|
return resp, fmt.Errorf("Backup fehlgeschlagen: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return resp, fmt.Errorf("ungueltiges Response-Format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save backup to DB (same logic as triggerBackup handler)
|
||||||
|
configB64, _ := data["config"].(string)
|
||||||
|
hash, _ := data["hash"].(string)
|
||||||
|
sizeFloat, _ := data["size"].(float64)
|
||||||
|
size := int(sizeFloat)
|
||||||
|
|
||||||
|
configXML, err := base64.StdEncoding.DecodeString(configB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Base64-Dekodierung fehlgeschlagen")
|
||||||
|
}
|
||||||
|
|
||||||
|
backupID, isNew, err := s.db.SaveConfigBackup(agentID, hash, size, string(configXML))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Backup speichern fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "Backup gespeichert"
|
||||||
|
if !isNew {
|
||||||
|
msg = "Config unveraendert (Hash identisch)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"message": msg,
|
||||||
|
"backup_id": backupID,
|
||||||
|
"sha256": hash,
|
||||||
|
"size": size,
|
||||||
|
"new": isNew,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
return nil, fmt.Errorf("Timeout: Agent hat nicht rechtzeitig geantwortet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) executeUpdate(agentID string) (interface{}, error) {
|
||||||
|
if !s.hub.IsAgentConnected(agentID) {
|
||||||
|
return nil, fmt.Errorf("Agent nicht verbunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First: update_check
|
||||||
|
checkCmdID := generateID()
|
||||||
|
checkCmd := map[string]interface{}{
|
||||||
|
"type": "command",
|
||||||
|
"id": checkCmdID,
|
||||||
|
"command": "update_check",
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCh := s.hub.RegisterResponseWaiter(checkCmdID)
|
||||||
|
defer s.hub.UnregisterResponseWaiter(checkCmdID)
|
||||||
|
|
||||||
|
checkJSON, _ := json.Marshal(checkCmd)
|
||||||
|
if err := s.hub.SendToAgent(agentID, checkJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("Update-Check senden fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkResult map[string]interface{}
|
||||||
|
select {
|
||||||
|
case resp := <-checkCh:
|
||||||
|
checkResult = resp
|
||||||
|
case <-time.After(120 * time.Second):
|
||||||
|
return nil, fmt.Errorf("Timeout beim Update-Check")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: update
|
||||||
|
updateCmdID := generateID()
|
||||||
|
updateCmd := map[string]interface{}{
|
||||||
|
"type": "command",
|
||||||
|
"id": updateCmdID,
|
||||||
|
"command": "update",
|
||||||
|
"params": map[string]interface{}{"reboot": false},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCh := s.hub.RegisterResponseWaiter(updateCmdID)
|
||||||
|
defer s.hub.UnregisterResponseWaiter(updateCmdID)
|
||||||
|
|
||||||
|
updateJSON, _ := json.Marshal(updateCmd)
|
||||||
|
if err := s.hub.SendToAgent(agentID, updateJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("Update-Command senden fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-updateCh:
|
||||||
|
respStatus, _ := resp["status"].(string)
|
||||||
|
if respStatus != "ok" {
|
||||||
|
errMsg, _ := resp["error"].(string)
|
||||||
|
return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"message": "Update ausgefuehrt",
|
||||||
|
"check_result": checkResult,
|
||||||
|
"update_result": resp,
|
||||||
|
}, nil
|
||||||
|
case <-time.After(900 * time.Second):
|
||||||
|
return nil, fmt.Errorf("Timeout beim Update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) {
|
||||||
|
payload, err := json.Marshal(task)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err)
|
||||||
|
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("JSON-Fehler: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err)
|
||||||
|
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Fehler: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
response := fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
sent := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||||
|
s.db.SetTaskWebhookResult(taskID, sent, response)
|
||||||
|
log.Printf("Scheduler: Webhook fuer Task #%d gesendet (Status: %d)", taskID, resp.StatusCode)
|
||||||
|
}
|
||||||
162
backend/api/tasks.go
Normal file
162
backend/api/tasks.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/tasks
|
||||||
|
func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit := 50
|
||||||
|
offset := 0
|
||||||
|
agentID := r.URL.Query().Get("agent_id")
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
action := r.URL.Query().Get("action")
|
||||||
|
|
||||||
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := r.URL.Query().Get("offset"); o != "" {
|
||||||
|
if n, err := strconv.Atoi(o); err == nil && n >= 0 {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, total, err := h.db.GetTasks(limit, offset, agentID, status, action)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Tasks laden fehlgeschlagen: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"data": tasks,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/tasks/{id}
|
||||||
|
func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Task-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.db.GetTask(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Task nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/tasks
|
||||||
|
func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
|
WebhookURL string `json:"webhook_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AgentID == "" || req.Action == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "agent_id und action sind erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate action
|
||||||
|
if req.Action != "backup" && req.Action != "update" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check agent exists
|
||||||
|
agent, _, err := h.db.GetAgent(req.AgentID)
|
||||||
|
if err != nil || agent == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from context
|
||||||
|
createdBy := "api-key"
|
||||||
|
if claims, ok := r.Context().Value(UserContextKey).(*JWTClaims); ok {
|
||||||
|
createdBy = claims.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.db.CreateTask(req.AgentID, req.Action, req.ScheduledAt, req.WebhookURL, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Task erstellen fehlgeschlagen: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Task erstellen fehlgeschlagen: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "task.create", "task", strconv.Itoa(task.ID), agent.Name, req.Action)
|
||||||
|
|
||||||
|
// If scheduled_at is in the past or now, trigger immediate execution
|
||||||
|
if req.ScheduledAt == "" {
|
||||||
|
// Already scheduled for now, scheduler will pick it up within 30s
|
||||||
|
} else {
|
||||||
|
schedTime, err := time.Parse(time.RFC3339, req.ScheduledAt)
|
||||||
|
if err == nil && !schedTime.After(time.Now()) {
|
||||||
|
// In the past — scheduler picks it up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/tasks/{id}/cancel
|
||||||
|
func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Task-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.CancelTask(id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "task.cancel", "task", idStr, "", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Task abgebrochen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/tasks/{id}
|
||||||
|
func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Task-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DeleteTask(id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "task.delete", "task", idStr, "", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Task geloescht"})
|
||||||
|
}
|
||||||
@ -227,6 +227,24 @@ func (d *Database) migrate() error {
|
|||||||
d.db.Exec(`INSERT INTO system_settings (key, value) VALUES ($1, '') ON CONFLICT DO NOTHING`, k)
|
d.db.Exec(`INSERT INTO system_settings (key, value) VALUES ($1, '') ON CONFLICT DO NOTHING`, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tasks Tabelle
|
||||||
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
result JSONB,
|
||||||
|
webhook_url TEXT,
|
||||||
|
webhook_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
webhook_response TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`)
|
||||||
|
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_status_scheduled ON tasks(status, scheduled_at)`)
|
||||||
|
|
||||||
// Retention Policy (90 Tage)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
|
|||||||
325
backend/db/tasks.go
Normal file
325
backend/db/tasks.go
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string) (*models.Task, error) {
|
||||||
|
// Validate action
|
||||||
|
validActions := map[string]bool{"backup": true, "update": true}
|
||||||
|
if !validActions[action] {
|
||||||
|
return nil, fmt.Errorf("ungueltige Aktion: %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedTime time.Time
|
||||||
|
var err error
|
||||||
|
if scheduledAt == "" {
|
||||||
|
schedTime = time.Now()
|
||||||
|
} else {
|
||||||
|
schedTime, err = time.Parse(time.RFC3339, scheduledAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ungueltiges Datum: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var task models.Task
|
||||||
|
err = d.db.QueryRow(`
|
||||||
|
INSERT INTO tasks (agent_id, action, scheduled_at, webhook_url, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, agent_id, action, status, scheduled_at, webhook_url, COALESCE(created_by, ''), created_at
|
||||||
|
`, agentID, action, schedTime, webhookURL, createdBy).Scan(
|
||||||
|
&task.ID, &task.AgentID, &task.Action, &task.Status,
|
||||||
|
&schedTime, &task.WebhookURL, &task.CreatedBy, &schedTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Task erstellen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read cleanly
|
||||||
|
return d.GetTask(task.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetTask(id int) (*models.Task, error) {
|
||||||
|
var t models.Task
|
||||||
|
var scheduledAt, createdAt time.Time
|
||||||
|
var startedAt, finishedAt sql.NullTime
|
||||||
|
var resultJSON sql.NullString
|
||||||
|
var webhookURL, webhookResponse, createdBy sql.NullString
|
||||||
|
var agentName, customerName sql.NullString
|
||||||
|
|
||||||
|
err := d.db.QueryRow(`
|
||||||
|
SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''),
|
||||||
|
t.action, t.status, t.scheduled_at, t.started_at, t.finished_at,
|
||||||
|
t.result, t.webhook_url, t.webhook_sent, t.webhook_response,
|
||||||
|
t.created_by, t.created_at
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN agents a ON a.id = t.agent_id
|
||||||
|
LEFT JOIN customers c ON c.id = a.customer_id
|
||||||
|
WHERE t.id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&t.ID, &t.AgentID, &agentName, &customerName,
|
||||||
|
&t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt,
|
||||||
|
&resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
|
||||||
|
&createdBy, &createdAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.AgentName = agentName.String
|
||||||
|
t.CustomerName = customerName.String
|
||||||
|
t.ScheduledAt = scheduledAt.Format(time.RFC3339)
|
||||||
|
t.CreatedAt = createdAt.Format(time.RFC3339)
|
||||||
|
if startedAt.Valid {
|
||||||
|
s := startedAt.Time.Format(time.RFC3339)
|
||||||
|
t.StartedAt = &s
|
||||||
|
}
|
||||||
|
if finishedAt.Valid {
|
||||||
|
s := finishedAt.Time.Format(time.RFC3339)
|
||||||
|
t.FinishedAt = &s
|
||||||
|
}
|
||||||
|
if resultJSON.Valid {
|
||||||
|
var r interface{}
|
||||||
|
json.Unmarshal([]byte(resultJSON.String), &r)
|
||||||
|
t.Result = r
|
||||||
|
}
|
||||||
|
if webhookURL.Valid {
|
||||||
|
t.WebhookURL = webhookURL.String
|
||||||
|
}
|
||||||
|
if webhookResponse.Valid {
|
||||||
|
t.WebhookResponse = webhookResponse.String
|
||||||
|
}
|
||||||
|
if createdBy.Valid {
|
||||||
|
t.CreatedBy = createdBy.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetTasks(limit, offset int, agentID, status, action string) ([]models.Task, int, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
where := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if agentID != "" {
|
||||||
|
where = append(where, fmt.Sprintf("t.agent_id = $%d", argIdx))
|
||||||
|
args = append(args, agentID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
where = append(where, fmt.Sprintf("t.status = $%d", argIdx))
|
||||||
|
args = append(args, status)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if action != "" {
|
||||||
|
where = append(where, fmt.Sprintf("t.action = $%d", argIdx))
|
||||||
|
args = append(args, action)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
whereClause := ""
|
||||||
|
if len(where) > 0 {
|
||||||
|
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count
|
||||||
|
var total int
|
||||||
|
countQuery := "SELECT COUNT(*) FROM tasks t " + whereClause
|
||||||
|
if err := d.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''),
|
||||||
|
t.action, t.status, t.scheduled_at, t.started_at, t.finished_at,
|
||||||
|
t.result, t.webhook_url, t.webhook_sent, t.webhook_response,
|
||||||
|
t.created_by, t.created_at
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN agents a ON a.id = t.agent_id
|
||||||
|
LEFT JOIN customers c ON c.id = a.customer_id
|
||||||
|
%s ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d
|
||||||
|
`, whereClause, argIdx, argIdx+1)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := d.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tasks []models.Task
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Task
|
||||||
|
var scheduledAt, createdAt time.Time
|
||||||
|
var startedAt, finishedAt sql.NullTime
|
||||||
|
var resultJSON sql.NullString
|
||||||
|
var webhookURL, webhookResponse, createdBy sql.NullString
|
||||||
|
var agentName, customerName sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.AgentID, &agentName, &customerName,
|
||||||
|
&t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt,
|
||||||
|
&resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
|
||||||
|
&createdBy, &createdAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.AgentName = agentName.String
|
||||||
|
t.CustomerName = customerName.String
|
||||||
|
t.ScheduledAt = scheduledAt.Format(time.RFC3339)
|
||||||
|
t.CreatedAt = createdAt.Format(time.RFC3339)
|
||||||
|
if startedAt.Valid {
|
||||||
|
s := startedAt.Time.Format(time.RFC3339)
|
||||||
|
t.StartedAt = &s
|
||||||
|
}
|
||||||
|
if finishedAt.Valid {
|
||||||
|
s := finishedAt.Time.Format(time.RFC3339)
|
||||||
|
t.FinishedAt = &s
|
||||||
|
}
|
||||||
|
if resultJSON.Valid {
|
||||||
|
var r interface{}
|
||||||
|
json.Unmarshal([]byte(resultJSON.String), &r)
|
||||||
|
t.Result = r
|
||||||
|
}
|
||||||
|
if webhookURL.Valid {
|
||||||
|
t.WebhookURL = webhookURL.String
|
||||||
|
}
|
||||||
|
if webhookResponse.Valid {
|
||||||
|
t.WebhookResponse = webhookResponse.String
|
||||||
|
}
|
||||||
|
if createdBy.Valid {
|
||||||
|
t.CreatedBy = createdBy.String
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
}
|
||||||
|
if tasks == nil {
|
||||||
|
tasks = []models.Task{}
|
||||||
|
}
|
||||||
|
return tasks, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) UpdateTaskStatus(id int, status string, result interface{}) error {
|
||||||
|
var resultJSON *string
|
||||||
|
if result != nil {
|
||||||
|
b, err := json.Marshal(result)
|
||||||
|
if err == nil {
|
||||||
|
s := string(b)
|
||||||
|
resultJSON = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "running":
|
||||||
|
_, err := d.db.Exec(
|
||||||
|
"UPDATE tasks SET status = $1, started_at = $2 WHERE id = $3",
|
||||||
|
status, now, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
case "completed", "failed":
|
||||||
|
_, err := d.db.Exec(
|
||||||
|
"UPDATE tasks SET status = $1, finished_at = $2, result = $3 WHERE id = $4",
|
||||||
|
status, now, resultJSON, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
_, err := d.db.Exec("UPDATE tasks SET status = $1 WHERE id = $2", status, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) SetTaskWebhookResult(id int, sent bool, response string) error {
|
||||||
|
// Truncate response to 500 chars
|
||||||
|
if len(response) > 500 {
|
||||||
|
response = response[:500]
|
||||||
|
}
|
||||||
|
_, err := d.db.Exec(
|
||||||
|
"UPDATE tasks SET webhook_sent = $1, webhook_response = $2 WHERE id = $3",
|
||||||
|
sent, response, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetDueTasks() ([]models.Task, error) {
|
||||||
|
rows, err := d.db.Query(`
|
||||||
|
SELECT t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''),
|
||||||
|
t.action, t.status, t.scheduled_at, t.webhook_url, t.created_at
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN agents a ON a.id = t.agent_id
|
||||||
|
LEFT JOIN customers c ON c.id = a.customer_id
|
||||||
|
WHERE t.status = 'scheduled' AND t.scheduled_at <= NOW()
|
||||||
|
ORDER BY t.scheduled_at ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tasks []models.Task
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Task
|
||||||
|
var scheduledAt, createdAt time.Time
|
||||||
|
var webhookURL sql.NullString
|
||||||
|
var agentName, customerName sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.AgentID, &agentName, &customerName,
|
||||||
|
&t.Action, &t.Status, &scheduledAt, &webhookURL, &createdAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.AgentName = agentName.String
|
||||||
|
t.CustomerName = customerName.String
|
||||||
|
t.ScheduledAt = scheduledAt.Format(time.RFC3339)
|
||||||
|
t.CreatedAt = createdAt.Format(time.RFC3339)
|
||||||
|
if webhookURL.Valid {
|
||||||
|
t.WebhookURL = webhookURL.String
|
||||||
|
}
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
}
|
||||||
|
return tasks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) CancelTask(id int) error {
|
||||||
|
res, err := d.db.Exec(
|
||||||
|
"UPDATE tasks SET status = 'cancelled' WHERE id = $1 AND status = 'scheduled'",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("Task nicht gefunden oder nicht mehr im Status 'scheduled'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) DeleteTask(id int) error {
|
||||||
|
res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("Task nicht gefunden")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -57,6 +57,10 @@ func main() {
|
|||||||
mon := monitor.New(database)
|
mon := monitor.New(database)
|
||||||
go mon.Run()
|
go mon.Run()
|
||||||
|
|
||||||
|
// Task-Scheduler starten
|
||||||
|
scheduler := api.NewTaskScheduler(database, hub)
|
||||||
|
go scheduler.Run()
|
||||||
|
|
||||||
// Config API-Keys in DB migrieren (einmalig)
|
// Config API-Keys in DB migrieren (einmalig)
|
||||||
database.MigrateConfigAPIKeys(cfg.APIKeys)
|
database.MigrateConfigAPIKeys(cfg.APIKeys)
|
||||||
|
|
||||||
|
|||||||
19
backend/models/task.go
Normal file
19
backend/models/task.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
AgentName string `json:"agent_name,omitempty"`
|
||||||
|
CustomerName string `json:"customer_name,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
|
StartedAt *string `json:"started_at,omitempty"`
|
||||||
|
FinishedAt *string `json:"finished_at,omitempty"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
WebhookURL string `json:"webhook_url,omitempty"`
|
||||||
|
WebhookSent bool `json:"webhook_sent"`
|
||||||
|
WebhookResponse string `json:"webhook_response,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
@ -292,6 +292,29 @@ class ApiClient {
|
|||||||
return this.post('/api/v1/agents/request-update-all')
|
return this.post('/api/v1/agents/request-update-all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
getTasks(params = {}) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params.limit) q.set('limit', params.limit)
|
||||||
|
if (params.offset) q.set('offset', params.offset)
|
||||||
|
if (params.agent_id) q.set('agent_id', params.agent_id)
|
||||||
|
if (params.status) q.set('status', params.status)
|
||||||
|
if (params.action) q.set('action', params.action)
|
||||||
|
return this.get(`/api/v1/tasks?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask(data) {
|
||||||
|
return this.post('/api/v1/tasks', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTask(id) {
|
||||||
|
return this.post(`/api/v1/tasks/${id}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTask(id) {
|
||||||
|
return this.del(`/api/v1/tasks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Installer
|
// Installer
|
||||||
getInstallerInfo() {
|
getInstallerInfo() {
|
||||||
return this.get('/api/v1/installer')
|
return this.get('/api/v1/installer')
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { copyToClipboard } from '../utils/clipboard'
|
|||||||
import { useSettingsStore } from '../stores/settings'
|
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,
|
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const allTabs = [
|
const allTabs = [
|
||||||
@ -20,6 +20,7 @@ const allTabs = [
|
|||||||
{ id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] },
|
{ id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] },
|
||||||
{ id: 'updates', label: 'Updates', platforms: ['freebsd'] },
|
{ id: 'updates', label: 'Updates', platforms: ['freebsd'] },
|
||||||
{ id: 'security', label: 'Sicherheit', platforms: ['freebsd'] },
|
{ id: 'security', label: 'Sicherheit', platforms: ['freebsd'] },
|
||||||
|
{ id: 'tasks', label: 'Aufgaben', platforms: ['freebsd', 'linux'] },
|
||||||
{ id: 'backups', label: 'Backups' },
|
{ id: 'backups', label: 'Backups' },
|
||||||
{ id: 'agent', label: 'Agent' },
|
{ id: 'agent', label: 'Agent' },
|
||||||
]
|
]
|
||||||
@ -129,6 +130,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
|||||||
<UpdatesTab agentId={agentId} />
|
<UpdatesTab agentId={agentId} />
|
||||||
) : tab === 'security' ? (
|
) : tab === 'security' ? (
|
||||||
<SecurityTab sys={sys} />
|
<SecurityTab sys={sys} />
|
||||||
|
) : tab === 'tasks' ? (
|
||||||
|
<TasksTab agentId={agentId} />
|
||||||
) : tab === 'backups' ? (
|
) : tab === 'backups' ? (
|
||||||
platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
||||||
) : tab === 'agent' ? (
|
) : tab === 'agent' ? (
|
||||||
@ -1494,6 +1497,236 @@ function SecurityTab({ sys }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TasksTab({ agentId }) {
|
||||||
|
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 loadTasks = () => {
|
||||||
|
api.getTasks({ agent_id: agentId, limit: 50 })
|
||||||
|
.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])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
agent_id: agentId,
|
||||||
|
action: newTask.action,
|
||||||
|
}
|
||||||
|
if (newTask.scheduled_at) {
|
||||||
|
// Convert datetime-local to RFC3339
|
||||||
|
const d = new Date(newTask.scheduled_at)
|
||||||
|
data.scheduled_at = d.toISOString()
|
||||||
|
}
|
||||||
|
if (newTask.webhook_url) data.webhook_url = newTask.webhook_url
|
||||||
|
await api.createTask(data)
|
||||||
|
setShowDialog(false)
|
||||||
|
setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' })
|
||||||
|
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 handleDelete = async (id) => {
|
||||||
|
if (!confirm('Task wirklich loeschen?')) return
|
||||||
|
try {
|
||||||
|
await api.deleteTask(id)
|
||||||
|
loadTasks()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'
|
||||||
|
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
</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 */}
|
||||||
|
{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="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">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 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}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BackupsTab({ agentId }) {
|
function BackupsTab({ agentId }) {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user