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:
2026-05-03 13:42:50 +01:00
parent 5f2845c331
commit a4823193e7
2 changed files with 110 additions and 31 deletions
+37 -29
View File
@@ -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>