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"}} -