Feature: Task Scheduler - planned tasks with webhook support

This commit is contained in:
cynfo3000
2026-03-05 08:59:53 +01:00
parent e1a3246075
commit 7908ae26a0
9 changed files with 1036 additions and 1 deletions
+7
View File
@@ -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("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
h.setupTunnelRoutes(mux, hub)
}
+244
View 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
View 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"})
}
+18
View File
@@ -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)
}
// 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)
d.setupRetention()
+325
View 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
}
+4
View File
@@ -57,6 +57,10 @@ func main() {
mon := monitor.New(database)
go mon.Run()
// Task-Scheduler starten
scheduler := api.NewTaskScheduler(database, hub)
go scheduler.Run()
// Config API-Keys in DB migrieren (einmalig)
database.MigrateConfigAPIKeys(cfg.APIKeys)
+19
View 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"`
}