diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 1c2b728..755e8fd 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -103,11 +103,64 @@ func (s *Server) version() string { // dashboardPage is the data the dashboard template renders against. type dashboardPage struct { - Hosts []store.Host + Hosts []dashboardHostRow HostCount int Summary store.FleetSummary } +// dashboardHostRow carries a host plus the per-row Run-now decision +// the host_row partial needs. The decision is computed server-side +// once per render rather than recomputed in the template. +type dashboardHostRow struct { + Host store.Host + // RunAllScheduleID is the ID of the single schedule that covers + // every source group on the host. Empty when zero or 2+ schedules + // match — in that case the row shows "Open →" instead of a Run-now + // button (the operator picks per-group from the host detail). + RunAllScheduleID string +} + +// pickRunAllSchedule returns the ID of the single schedule whose +// source-group set ⊇ every source group on the host. Returns "" when +// zero or 2+ such "covering" schedules exist (operator-disambiguation +// belongs on the host detail, not the dashboard one-click). +func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string { + if len(groups) == 0 || len(scheds) == 0 { + return "" + } + groupIDs := make(map[string]struct{}, len(groups)) + for _, g := range groups { + groupIDs[g.ID] = struct{}{} + } + matched := "" + for _, sc := range scheds { + if !sc.Enabled { + continue + } + // Treat sc.SourceGroupIDs as a set; check it covers every group. + got := make(map[string]struct{}, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + got[gid] = struct{}{} + } + covers := true + for gid := range groupIDs { + if _, ok := got[gid]; !ok { + covers = false + break + } + } + if !covers { + continue + } + if matched != "" { + // Two distinct covering schedules — ambiguous, bail out. + return "" + } + matched = sc.ID + } + return matched +} + // handleUIDashboard is the root page. Auth-gated; falls through to // /login if there is no session. func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -129,10 +182,28 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) return } + // Per-host: pick the single covering schedule (if any) so the row + // can render a one-click Run-now where it's unambiguous. Two store + // calls per host — fine at fleet sizes we care about. + rows := make([]dashboardHostRow, 0, len(hosts)) + for _, h := range hosts { + row := dashboardHostRow{Host: h} + groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID) + if gerr != nil { + slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr) + } + scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID) + if serr != nil { + slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr) + } + row.RunAllScheduleID = pickRunAllSchedule(scheds, groups) + rows = append(rows, row) + } + view := s.baseView(u, "dashboard") view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ - Hosts: hosts, + Hosts: rows, HostCount: len(hosts), Summary: summary, } diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index d1d128e..98b27ea 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -1,58 +1,66 @@ {{define "host_row"}} -
- {{.Name}} +{{$h := .Host}} +
+ {{$h.Name}}
- {{- if eq .Status "online" -}} - - {{- else if eq .Status "degraded" -}} + {{- if eq $h.Status "online" -}} + + {{- else if eq $h.Status "degraded" -}} - {{- else if eq .Status "offline" -}} + {{- else if eq $h.Status "offline" -}} {{- else -}} {{- end -}}
-
{{.Name}}
-
{{.OS}}/{{.Arch}}
+
{{$h.Name}}
+
{{$h.OS}}/{{$h.Arch}}
- {{- if .CurrentJobID -}} + {{- if $h.CurrentJobID -}} backup running…
- started {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "succeeded" -}} - succeeded · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "failed" -}} - failed · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "cancelled" -}} - cancelled · {{relTime .LastBackupAt}} - {{- else if eq .Status "offline" -}} - last seen {{relTime .LastSeenAt}} + started {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "succeeded" -}} + succeeded · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "failed" -}} + failed · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "cancelled" -}} + cancelled · {{relTime $h.LastBackupAt}} + {{- else if eq $h.Status "offline" -}} + last seen {{relTime $h.LastSeenAt}} {{- else -}} never run {{- end -}}
-
{{bytes .RepoSizeBytes}}
-
- {{- if eq .SnapshotCount 0 -}} +
{{bytes $h.RepoSizeBytes}}
+
+ {{- if eq $h.SnapshotCount 0 -}} {{- else -}} - {{comma .SnapshotCount}} + {{comma $h.SnapshotCount}} {{- end -}}
-
- {{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}} +
+ {{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
- {{- range .Tags -}} + {{- range $h.Tags -}} {{.}} {{- end -}}
- {{- if eq .Status "offline" -}} + {{- if eq $h.Status "offline" -}} offline - {{- else if .CurrentJobID -}} - View job → + {{- else if $h.CurrentJobID -}} + View job → + {{- else if .RunAllScheduleID -}} + {{- else -}} - Open → + Open → {{- end -}}