P2R-02 slice 5: dashboard row Run-now uses covering schedule
Replace the placeholder 'Open →' link with a per-host Run-now
decision computed server-side once per render:
* If the host has exactly one enabled schedule whose source-group
set covers every group on the host → primary 'Run all groups'
button (HX-POST to that schedule's /run endpoint, fires every
backup the host knows about in one click).
* Otherwise (zero matches, multiple matches, or any ambiguity) →
ghost 'Open →' link to /hosts/{id}/sources, where the operator
picks per-group from the source-group rows.
dashboardPage.Hosts moves from []store.Host to []dashboardHostRow
to carry the precomputed RunAllScheduleID; host_row.html now reads
.Host.* and .RunAllScheduleID. Two extra store calls per host on
dashboard render — fine at fleet sizes we care about; if we ever
need to support thousands of hosts we'll batch these queries.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
{{define "host_row"}}
|
||||
<div class="row-hover host-row clickable hairline {{.Status}}{{if eq (deref .LastBackupStatus) "failed"}} failed{{end}}">
|
||||
<a href="/hosts/{{.ID}}" class="row-link" aria-label="Open {{.Name}}">{{.Name}}</a>
|
||||
{{$h := .Host}}
|
||||
<div class="row-hover host-row clickable hairline {{$h.Status}}{{if eq (deref $h.LastBackupStatus) "failed"}} failed{{end}}">
|
||||
<a href="/hosts/{{$h.ID}}" class="row-link" aria-label="Open {{$h.Name}}">{{$h.Name}}</a>
|
||||
<div>
|
||||
{{- if eq .Status "online" -}}
|
||||
<span class="dot dot-online{{if .CurrentJobID}} pulse{{end}}"></span>
|
||||
{{- else if eq .Status "degraded" -}}
|
||||
{{- if eq $h.Status "online" -}}
|
||||
<span class="dot dot-online{{if $h.CurrentJobID}} pulse{{end}}"></span>
|
||||
{{- else if eq $h.Status "degraded" -}}
|
||||
<span class="dot dot-degraded"></span>
|
||||
{{- else if eq .Status "offline" -}}
|
||||
{{- else if eq $h.Status "offline" -}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{- else -}}
|
||||
<span class="dot dot-failed"></span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{.Name}}</div>
|
||||
<div class="mono text-ink-mid text-[12px]">{{.OS}}/{{.Arch}}</div>
|
||||
<div class="mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{$h.Name}}</div>
|
||||
<div class="mono text-ink-mid text-[12px]">{{$h.OS}}/{{$h.Arch}}</div>
|
||||
<div class="text-xs text-ink-mid">
|
||||
{{- if .CurrentJobID -}}
|
||||
{{- if $h.CurrentJobID -}}
|
||||
<span class="text-accent">backup running…</span><br>
|
||||
<span class="mono text-ink-fade">started {{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "succeeded" -}}
|
||||
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "failed" -}}
|
||||
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "cancelled" -}}
|
||||
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq .Status "offline" -}}
|
||||
<span class="text-ink-mute">last seen <span class="mono">{{relTime .LastSeenAt}}</span></span>
|
||||
<span class="mono text-ink-fade">started {{relTime $h.LastBackupAt}}</span>
|
||||
{{- else if eq (deref $h.LastBackupStatus) "succeeded" -}}
|
||||
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
|
||||
{{- else if eq (deref $h.LastBackupStatus) "failed" -}}
|
||||
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
|
||||
{{- else if eq (deref $h.LastBackupStatus) "cancelled" -}}
|
||||
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
|
||||
{{- else if eq $h.Status "offline" -}}
|
||||
<span class="text-ink-mute">last seen <span class="mono">{{relTime $h.LastSeenAt}}</span></span>
|
||||
{{- else -}}
|
||||
<span class="text-ink-fade italic">never run</span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes .RepoSizeBytes}}</div>
|
||||
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
|
||||
{{- if eq .SnapshotCount 0 -}}
|
||||
<div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes $h.RepoSizeBytes}}</div>
|
||||
<div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
|
||||
{{- if eq $h.SnapshotCount 0 -}}
|
||||
<span class="text-ink-fade">—</span>
|
||||
{{- else -}}
|
||||
{{comma .SnapshotCount}}
|
||||
{{comma $h.SnapshotCount}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right mono {{if gt .OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
|
||||
{{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}}
|
||||
<div class="text-right mono {{if gt $h.OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
|
||||
{{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
|
||||
</div>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{{- range .Tags -}}
|
||||
{{- range $h.Tags -}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right row-action">
|
||||
{{- if eq .Status "offline" -}}
|
||||
{{- if eq $h.Status "offline" -}}
|
||||
<span class="mono text-xs text-ink-fade">offline</span>
|
||||
{{- else if .CurrentJobID -}}
|
||||
<a href="/jobs/{{deref .CurrentJobID}}" class="btn btn-ghost">View job →</a>
|
||||
{{- else if $h.CurrentJobID -}}
|
||||
<a href="/jobs/{{deref $h.CurrentJobID}}" class="btn btn-ghost">View job →</a>
|
||||
{{- else if .RunAllScheduleID -}}
|
||||
<button class="btn btn-primary whitespace-nowrap"
|
||||
hx-post="/hosts/{{$h.ID}}/schedules/{{.RunAllScheduleID}}/run"
|
||||
hx-swap="none"
|
||||
hx-disabled-elt="this"
|
||||
title="fire every backup this host knows about">Run all groups</button>
|
||||
{{- else -}}
|
||||
<a href="/hosts/{{.ID}}" class="btn btn-ghost whitespace-nowrap" title="per-source-group Run-now lands in P2 Phase 4 — open the host">Open →</a>
|
||||
<a href="/hosts/{{$h.ID}}/sources" class="btn btn-ghost whitespace-nowrap"
|
||||
title="multiple schedules — pick a source group from the host detail">Open →</a>
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user