feat: Task Health Check + customer_number in webhook payload

- Task model: CustomerNumber field (K-Nummer aus customers.number)
- DB: taskSelectCols joined c.number, scanTask scans CustomerNumber
- Hub: WaitForAgentOnline() polls until agent reconnects or timeout
- Scheduler: executeUpdateWithHealthCheck() waits for agent reconnect after update
  - health_check: {agent_online: bool, checked_at: RFC3339} in result
  - Status: completed_with_warning wenn Update OK aber Agent offline bleibt
- Frontend: Task-Tabelle zeigt Status-Badge (lauft/fehler/warnung/abgebr.)
This commit is contained in:
cynfo3000 2026-03-05 10:53:08 +01:00
parent 721fd5008f
commit d4b6ca86e5
5 changed files with 96 additions and 13 deletions

View File

@ -62,22 +62,40 @@ func (s *TaskScheduler) executeTask(task models.Task) {
case "backup": case "backup":
result, taskErr = s.executeBackup(task.AgentID) result, taskErr = s.executeBackup(task.AgentID)
case "update": case "update":
result, taskErr = s.executeUpdate(task.AgentID, task.Reboot) result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout)
default: default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action) taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action)
} }
success := taskErr == nil success := taskErr == nil
healthCheckWarning := false
if !success { if !success {
result = map[string]string{"error": taskErr.Error()} // Check if it's a health check warning (update ran OK but agent didn't come back)
if resultMap, ok := result.(map[string]interface{}); ok {
if hc, ok := resultMap["health_check"].(map[string]interface{}); ok {
if online, ok := hc["agent_online"].(bool); ok && !online {
healthCheckWarning = true
}
}
}
if !healthCheckWarning {
result = map[string]string{"error": taskErr.Error()}
}
log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", task.ID, taskErr) log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", task.ID, taskErr)
} else { } else {
log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", task.ID) log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", task.ID)
} }
// Use CompleteRecurringTask for proper recurring handling // Use CompleteRecurringTask for proper recurring handling
// Health check warnings count as success for recurring schedule, but status = "completed_with_warning"
scheduleSuccess := success || healthCheckWarning
s.db.CompleteRecurringTask(task.ID, task.ScheduleType, task.ScheduleTime, s.db.CompleteRecurringTask(task.ID, task.ScheduleType, task.ScheduleTime,
task.ScheduleWeekday, task.ScheduleMonthday, success, result) task.ScheduleWeekday, task.ScheduleMonthday, scheduleSuccess, result)
// Override status for health check warnings
if healthCheckWarning {
s.db.UpdateTaskStatusDirect(task.ID, "completed_with_warning")
}
// Re-read task for webhook payload // Re-read task for webhook payload
fullTask, _ := s.db.GetTask(task.ID) fullTask, _ := s.db.GetTask(task.ID)
@ -156,6 +174,10 @@ func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) {
} }
func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) { func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) {
return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600)
}
func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) { if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden") return nil, fmt.Errorf("Agent nicht verbunden")
} }
@ -201,6 +223,7 @@ func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{},
return nil, fmt.Errorf("Update-Command senden fehlgeschlagen: %w", err) return nil, fmt.Errorf("Update-Command senden fehlgeschlagen: %w", err)
} }
var updateResult map[string]interface{}
select { select {
case resp := <-updateCh: case resp := <-updateCh:
respStatus, _ := resp["status"].(string) respStatus, _ := resp["status"].(string)
@ -208,15 +231,48 @@ func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{},
errMsg, _ := resp["error"].(string) errMsg, _ := resp["error"].(string)
return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg) return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg)
} }
return map[string]interface{}{ updateResult = resp
"message": "Update ausgefuehrt",
"check_result": checkResult,
"update_result": resp,
"reboot": reboot,
}, nil
case <-time.After(900 * time.Second): case <-time.After(900 * time.Second):
return nil, fmt.Errorf("Timeout beim Update") return nil, fmt.Errorf("Timeout beim Update")
} }
result := map[string]interface{}{
"message": "Update ausgefuehrt",
"check_result": checkResult,
"update_result": updateResult,
"reboot": reboot,
}
// Health Check: wait for agent to come back online
if healthCheck {
timeout := time.Duration(healthCheckTimeout) * time.Second
if timeout <= 0 {
timeout = 600 * time.Second
}
log.Printf("Scheduler: Health Check fuer Agent %s (Timeout: %v)", agentID, timeout)
// If no reboot, agent should still be connected — give it a moment for post-update restart
if !reboot {
time.Sleep(15 * time.Second)
}
checkedAt := time.Now().UTC().Format(time.RFC3339)
agentOnline := s.hub.WaitForAgentOnline(agentID, timeout)
result["health_check"] = map[string]interface{}{
"agent_online": agentOnline,
"checked_at": checkedAt,
}
if !agentOnline {
result["message"] = "Update ausgefuehrt, aber Agent nicht wieder online (Health Check fehlgeschlagen)"
log.Printf("Scheduler: Health Check fehlgeschlagen — Agent %s nach %v nicht online", agentID, timeout)
// Return result but signal warning via error
return result, fmt.Errorf("Health Check: Agent nach %v nicht online", timeout)
}
log.Printf("Scheduler: Health Check OK — Agent %s wieder online", agentID)
}
return result, nil
} }
func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) { func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) {

View File

@ -126,14 +126,14 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
var startedAt, finishedAt, lastRun, nextRun sql.NullTime var startedAt, finishedAt, lastRun, nextRun sql.NullTime
var resultJSON sql.NullString var resultJSON sql.NullString
var webhookURL, webhookResponse, createdBy sql.NullString var webhookURL, webhookResponse, createdBy sql.NullString
var agentName, customerName sql.NullString var agentName, customerName, customerNumber sql.NullString
var name, scheduleType, scheduleTime sql.NullString var name, scheduleType, scheduleTime sql.NullString
var scheduleWeekday, scheduleMonthday sql.NullInt32 var scheduleWeekday, scheduleMonthday sql.NullInt32
var reboot, healthCheck, active sql.NullBool var reboot, healthCheck, active sql.NullBool
var healthCheckTimeout, runCount sql.NullInt32 var healthCheckTimeout, runCount sql.NullInt32
err := scan( err := scan(
&t.ID, &t.AgentID, &agentName, &customerName, &t.ID, &t.AgentID, &agentName, &customerName, &customerNumber,
&name, &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt, &name, &t.Action, &t.Status, &scheduledAt, &startedAt, &finishedAt,
&resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse, &resultJSON, &webhookURL, &t.WebhookSent, &webhookResponse,
&createdBy, &createdAt, &createdBy, &createdAt,
@ -146,6 +146,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
t.AgentName = agentName.String t.AgentName = agentName.String
t.CustomerName = customerName.String t.CustomerName = customerName.String
t.CustomerNumber = customerNumber.String
t.Name = name.String t.Name = name.String
t.ScheduleType = scheduleType.String t.ScheduleType = scheduleType.String
if t.ScheduleType == "" { if t.ScheduleType == "" {
@ -208,7 +209,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
return &t, nil return &t, nil
} }
const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name, ''), COALESCE(c.number, ''),
t.name, t.action, t.status, t.scheduled_at, t.started_at, t.finished_at, 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.result, t.webhook_url, t.webhook_sent, t.webhook_response,
t.created_by, t.created_at, t.created_by, t.created_at,
@ -415,6 +416,12 @@ func (d *Database) CancelTask(id int) error {
return nil return nil
} }
// UpdateTaskStatusDirect sets only the status field (for post-completion overrides).
func (d *Database) UpdateTaskStatusDirect(id int, status string) error {
_, err := d.db.Exec("UPDATE tasks SET status = $1 WHERE id = $2", status, id)
return err
}
func (d *Database) DeleteTask(id int) error { func (d *Database) DeleteTask(id int) error {
res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id) res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id)
if err != nil { if err != nil {

View File

@ -5,6 +5,7 @@ type Task struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"` AgentName string `json:"agent_name,omitempty"`
CustomerName string `json:"customer_name,omitempty"` CustomerName string `json:"customer_name,omitempty"`
CustomerNumber string `json:"customer_number,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Action string `json:"action"` Action string `json:"action"`
Status string `json:"status"` Status string `json:"status"`

View File

@ -175,6 +175,19 @@ func (h *Hub) IsAgentConnected(agentID string) bool {
return exists return exists
} }
// WaitForAgentOnline polls until the agent reconnects or timeout is reached.
// Returns true if agent came online within the timeout, false otherwise.
func (h *Hub) WaitForAgentOnline(agentID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if h.IsAgentConnected(agentID) {
return true
}
time.Sleep(10 * time.Second)
}
return false
}
func (h *Hub) GetConnectedAgents() []string { func (h *Hub) GetConnectedAgents() []string {
h.agentsMux.RLock() h.agentsMux.RLock()
defer h.agentsMux.RUnlock() defer h.agentsMux.RUnlock()

View File

@ -1821,7 +1821,13 @@ function TasksTab({ agentId, agentName }) {
{tasks.map(t => ( {tasks.map(t => (
<tr key={t.id} className={!t.active ? 'opacity-50' : ''}> <tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
<td className="px-3 py-2"> <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'} /> <div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${t.active ? 'bg-green-500' : 'bg-gray-500'}`} title={t.active ? 'Aktiv' : 'Inaktiv'} />
{t.status === 'running' && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300">lauft</span>}
{t.status === 'failed' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">fehler</span>}
{t.status === 'completed_with_warning' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-900/50 text-yellow-300" title="Update OK, aber Agent nicht wieder online">warnung</span>}
{t.status === 'cancelled' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">abgebr.</span>}
</div>
</td> </td>
<td className="px-3 py-2 text-white">{t.name || `Job #${t.id}`}</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-300 capitalize">{t.action}</td>