From 4035c44be3f7a019c9fb1b467e30467c9e6a41ae Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 13:25:31 +0100 Subject: [PATCH] =?UTF-8?q?P2R-02=20follow-up:=20schedule=20Run-now=20feed?= =?UTF-8?q?back=20(single=20=E2=86=92=20job=20log,=20multi=20=E2=86=92=20t?= =?UTF-8?q?oast)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules tab Run-now used to silently HX-Redirect back to the list, leaving the operator wondering whether the click registered. Now: * Single-source-group schedule → HX-Redirect to that one job's live log, matching the per-source-group Run-now UX from Sources. * Multi-group schedule → stay on the schedules list and fire a success toast ("N backups dispatched: ") via the existing rm:toast HX-Trigger channel, so the operator sees clear acknowledgement without losing their place. dispatchBackupForGroup now returns the persisted job ID so the caller can choose between job-log redirect and toast feedback; on any internal failure it returns "" and the warning still hits slog as before. The cron-fired path (dispatchScheduledJob) ignores the return value, behaviour unchanged. --- internal/server/http/schedule_push.go | 14 ++++++++--- internal/server/http/ui_schedules.go | 35 +++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index 6c2c692..e82dfc6 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -167,7 +167,12 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * // dispatchBackupForGroup builds and sends a single backup command.run // envelope on conn for the given group. Persists the job row first so // the live log viewer can subscribe to it. -func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) { +// dispatchBackupForGroup persists a backup job row, sends the +// command.run envelope to the agent, and audit-logs the dispatch. +// Returns the persisted job ID on success, or "" on any failure +// (failures are slog.Warn-ed). Callers may use the returned ID to, +// e.g., redirect the UI to the live job log. +func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) string { jobID := ulid.Make().String() now := time.Now().UTC() scheduleRef := scheduleID @@ -181,7 +186,7 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host }); err != nil { slog.Warn("schedule.fire: persist job", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "err", err) - return + return "" } // Backup ignores RetentionPolicy — the forget cadence lives on // host_repo_maintenance and is driven by the server-side ticker @@ -196,14 +201,14 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host if err != nil { slog.Warn("schedule.fire: marshal command.run", "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "" } sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := conn.Send(sendCtx, env); err != nil { slog.Warn("schedule.fire: send command.run", "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "" } _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ ID: ulid.Make().String(), @@ -216,4 +221,5 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host slog.Info("schedule.fire: dispatched backup", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt) + return jobID } diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 8352313..61f9e64 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -2,9 +2,11 @@ package http import ( "context" + "encoding/json" "errors" "log/slog" stdhttp "net/http" + "strconv" "strings" "time" @@ -324,6 +326,8 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() now := time.Now().UTC() + type fired struct{ groupName, jobID string } + dispatched := make([]fired, 0, len(sc.SourceGroupIDs)) for _, gid := range sc.SourceGroupIDs { g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid) if gerr != nil { @@ -331,14 +335,35 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques "host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr) continue } - s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + if jobID != "" { + dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID}) + } } if wantsHTML(r) { - // HX-Redirect would jump to a single job log, but a multi-group - // fire produces N jobs. Bounce back to the list — the operator - // can drill into individual jobs from there. - w.Header().Set("HX-Redirect", "/hosts/"+host.ID+"/schedules") + switch len(dispatched) { + case 0: + stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError) + return + case 1: + // Single-group schedule: jump straight to the live job log, + // same UX as per-source-group Run-now from the Sources tab. + w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID) + default: + // Multi-group: stay on the schedules tab and toast the + // summary. Direct the operator to one of the job logs via + // the toast (the most recent job ID is fine). + names := make([]string, 0, len(dispatched)) + for _, f := range dispatched { + names = append(names, f.groupName) + } + msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ") + payload, _ := json.Marshal(map[string]any{ + "rm:toast": map[string]string{"level": "success", "message": msg}, + }) + w.Header().Set("HX-Trigger", string(payload)) + } } w.WriteHeader(stdhttp.StatusNoContent) }