ui: update chip + per-host button

- Surface UpdateAvailable + TargetVersion on the dashboard host row,
  the host_chrome header, and the JSON Host shape.
- New host_update_chip partial renders an amber out-of-date pill
  next to the agent-version display when the host's agent trails
  the server.
- Host detail right-rail gains an admin-only Update agent button
  (disabled when host is offline or already updating).
- New .update-chip and .btn-amber CSS tokens; tailwind output
  refreshed.
This commit is contained in:
2026-05-06 22:20:40 +01:00
parent 3fa7be51a5
commit 94441a5371
8 changed files with 161 additions and 5 deletions
+5
View File
@@ -4,6 +4,7 @@ import (
stdhttp "net/http" stdhttp "net/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
) )
// hostView is the JSON projection of a Host row. Same shape as the // hostView is the JSON projection of a Host row. Same shape as the
@@ -27,6 +28,8 @@ type hostView struct {
RepoSizeBytes int64 `json:"repo_size_bytes"` RepoSizeBytes int64 `json:"repo_size_bytes"`
SnapshotCount int `json:"snapshot_count"` SnapshotCount int `json:"snapshot_count"`
OpenAlertCount int `json:"open_alert_count"` OpenAlertCount int `json:"open_alert_count"`
UpdateAvailable bool `json:"update_available"`
TargetVersion string `json:"target_version,omitempty"`
} }
// handleListHosts returns the full fleet as JSON. Authenticated; the // handleListHosts returns the full fleet as JSON. Authenticated; the
@@ -85,6 +88,8 @@ func hostToView(h store.Host) hostView {
RepoSizeBytes: h.RepoSizeBytes, RepoSizeBytes: h.RepoSizeBytes,
SnapshotCount: h.SnapshotCount, SnapshotCount: h.SnapshotCount,
OpenAlertCount: h.OpenAlertCount, OpenAlertCount: h.OpenAlertCount,
TargetVersion: version.Version,
UpdateAvailable: h.AgentVersion != "" && h.AgentVersion != version.Version,
} }
if v.Tags == nil { if v.Tags == nil {
v.Tags = []string{} v.Tags = []string{}
+63 -2
View File
@@ -23,6 +23,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
"gitea.dcglab.co.uk/steve/restic-manager/web" "gitea.dcglab.co.uk/steve/restic-manager/web"
) )
@@ -155,6 +156,10 @@ type dashboardPage struct {
// when it's already active). Pre-computed so the template stays // when it's already active). Pre-computed so the template stays
// dumb. // dumb.
SortURL map[string]string SortURL map[string]string
// UpdatesBehind is the count of online hosts whose agent_version
// trails the server. Surfaces as the dashboard "N hosts behind"
// hero tile and links to ?updates=behind.
UpdatesBehind int
} }
// dashboardFilter holds the parsed query-string filter state. // dashboardFilter holds the parsed query-string filter state.
@@ -165,6 +170,10 @@ type dashboardFilter struct {
Tag string // mirrors ActiveTag for round-trip on links Tag string // mirrors ActiveTag for round-trip on links
Sort string // column key (see sortDashboard) Sort string // column key (see sortDashboard)
Dir string // "asc" | "desc" Dir string // "asc" | "desc"
// Updates narrows to hosts whose agent is behind the server's
// version. Only valid value today is "behind"; empty means no
// filter.
Updates string
} }
// dashboardHostRow carries a host plus the per-row Run-now decision // dashboardHostRow carries a host plus the per-row Run-now decision
@@ -180,6 +189,13 @@ type dashboardHostRow struct {
// NextRun is the next-fire time of RunAllScheduleID (when set), // NextRun is the next-fire time of RunAllScheduleID (when set),
// computed server-side from its cron. nil otherwise. // computed server-side from its cron. nil otherwise.
NextRun *time.Time NextRun *time.Time
// UpdateAvailable is true when the host's agent has connected at
// least once AND its agent_version differs from the server's. Used
// by the host_row partial to render the update-available chip.
UpdateAvailable bool
// TargetVersion is the server's build version, surfaced in the
// chip's tooltip and label.
TargetVersion string
} }
// pickRunAllSchedule returns the ID of the single schedule whose // pickRunAllSchedule returns the ID of the single schedule whose
@@ -255,7 +271,11 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
// calls per host — fine at fleet sizes we care about. // calls per host — fine at fleet sizes we care about.
rows := make([]dashboardHostRow, 0, len(hosts)) rows := make([]dashboardHostRow, 0, len(hosts))
for _, h := range hosts { for _, h := range hosts {
row := dashboardHostRow{Host: h} row := dashboardHostRow{
Host: h,
TargetVersion: version.Version,
UpdateAvailable: h.AgentVersion != "" && h.AgentVersion != version.Version,
}
groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID) groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID)
if gerr != nil { if gerr != nil {
slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr) slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr)
@@ -289,6 +309,13 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
critOpenCount = len(crit) critOpenCount = len(crit)
} }
updatesBehind := 0
for _, h := range allHosts {
if h.Status == "online" && h.AgentVersion != "" && h.AgentVersion != version.Version {
updatesBehind++
}
}
view := s.baseView(r, u) view := s.baseView(r, u)
view.Page = dashboardPage{ view.Page = dashboardPage{
Hosts: rows, Hosts: rows,
@@ -302,6 +329,7 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
Filter: filter, Filter: filter,
RefreshURL: "/?" + filter.encode(), RefreshURL: "/?" + filter.encode(),
SortURL: buildDashboardSortURLs(filter), SortURL: buildDashboardSortURLs(filter),
UpdatesBehind: updatesBehind,
} }
if err := s.deps.UI.Render(w, "dashboard", view); err != nil { if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err) slog.Error("ui: render dashboard", "err", err)
@@ -320,6 +348,7 @@ func parseDashboardFilter(q url.Values) dashboardFilter {
Tag: q.Get("tag"), Tag: q.Get("tag"),
Sort: q.Get("sort"), Sort: q.Get("sort"),
Dir: q.Get("dir"), Dir: q.Get("dir"),
Updates: q.Get("updates"),
} }
if f.Sort == "" { if f.Sort == "" {
f.Sort = "name" f.Sort = "name"
@@ -352,6 +381,9 @@ func (f dashboardFilter) encode() string {
if f.Dir != "" && f.Dir != "asc" { if f.Dir != "" && f.Dir != "asc" {
v.Set("dir", f.Dir) v.Set("dir", f.Dir)
} }
if f.Updates != "" {
v.Set("updates", f.Updates)
}
return v.Encode() return v.Encode()
} }
@@ -402,6 +434,11 @@ func filterAndSortDashboardHosts(hosts []store.Host, f dashboardFilter) []store.
continue continue
} }
} }
if f.Updates == "behind" {
if h.AgentVersion == "" || h.AgentVersion == version.Version {
continue
}
}
out = append(out, h) out = append(out, h)
} }
sortDashboardHosts(out, f.Sort, f.Dir) sortDashboardHosts(out, f.Sort, f.Dir)
@@ -809,6 +846,20 @@ type hostChromeData struct {
SourceGroupCount int SourceGroupCount int
ScheduleCount int ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired) ScheduleVersion int64 // host_schedule_version (latest desired)
// UpdateAvailable + TargetVersion drive the agent-out-of-date chip
// in the host detail header. UpdateAvailable is true iff the host
// has connected at least once AND its agent_version != server's.
UpdateAvailable bool
TargetVersion string
// Online + UpdateInProgress drive the per-host "Update agent"
// button on host_detail. Online mirrors hub.Connected; pulled here
// so the button can disable when the host is unreachable.
Online bool
UpdateInProgress bool
// CanAdmin is true when the viewing user has admin role; used to
// gate the "Update agent" button. Kept on the chrome struct so any
// page reusing host_chrome already has it for free.
CanAdmin bool
// KnownTags is the union of tags already in use across the fleet, // KnownTags is the union of tags already in use across the fleet,
// used for autocomplete on the host-tags edit form. Cheap query. // used for autocomplete on the host-tags edit form. Cheap query.
KnownTags []string KnownTags []string
@@ -834,6 +885,14 @@ type hostChromeData struct {
// render the page with stale counts than 500 the whole tab. // render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData { func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb} d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
d.TargetVersion = version.Version
d.UpdateAvailable = host.AgentVersion != "" && host.AgentVersion != version.Version
if s.deps.Hub != nil {
d.Online = s.deps.Hub.Connected(host.ID)
}
if existing, _ := s.deps.Store.RunningUpdateJobForHost(r.Context(), host.ID); existing != "" {
d.UpdateInProgress = true
}
if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil { if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
d.SourceGroupCount = len(groups) d.SourceGroupCount = len(groups)
} else { } else {
@@ -972,8 +1031,10 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
view := s.baseView(r, u) view := s.baseView(r, u)
view.Title = host.Name + " · restic-manager" view.Title = host.Name + " · restic-manager"
chrome := s.loadHostChrome(r, *host, "snapshots", "snapshots")
chrome.CanAdmin = u.Role == string(store.RoleAdmin)
view.Page = hostDetailPage{ view.Page = hostDetailPage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), hostChromeData: chrome,
Snapshots: shown, Snapshots: shown,
SnapshotsShown: len(shown), SnapshotsShown: len(shown),
LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17), LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17),
File diff suppressed because one or more lines are too long
+59
View File
@@ -104,6 +104,65 @@
.btn-lg { font-size: 13px; padding: 9px 14px; } .btn-lg { font-size: 13px; padding: 9px 14px; }
.btn-block { width: 100%; justify-content: center; } .btn-block { width: 100%; justify-content: center; }
/* Amber action used for the per-host "Update agent" button and
the fleet-update Start button. Same warning palette as the
update-chip below. */
.btn-amber {
color: oklch(0.18 0.01 80);
background: var(--warn);
border-color: var(--warn);
}
.btn-amber:hover { filter: brightness(1.08); }
.btn-amber:disabled, .btn-amber[disabled] {
opacity: 0.45; cursor: not-allowed; pointer-events: none;
}
/* Update-available chip small amber pill rendered next to a host's
agent version (in the row OS column and in the host detail
header). Hidden when the host is up to date. */
.update-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px; font-weight: 500;
line-height: 1.4;
color: oklch(0.18 0.01 80);
background: color-mix(in oklch, var(--warn), transparent 30%);
border: 1px solid color-mix(in oklch, var(--warn), transparent 50%);
white-space: nowrap;
}
/* Hero tile large, clickable summary card on the dashboard.
Today only used by the "N hosts behind" tile; the existing
four summary boxes use bespoke grid markup. Add more variants
as adjacent dashboard tiles adopt this. */
.hero-tile {
display: flex; flex-direction: column; gap: 4px;
padding: 14px 16px;
border-radius: 7px;
border: 1px solid var(--line-soft);
background: var(--panel);
text-decoration: none;
transition: filter 120ms ease, background 120ms ease;
}
.hero-tile:hover { filter: brightness(1.08); }
.hero-tile .hero-num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 22px; font-weight: 500;
letter-spacing: -0.01em;
color: var(--ink);
}
.hero-tile .hero-label {
font-size: 11.5px;
color: var(--ink-mute);
}
.hero-tile--amber {
background: color-mix(in oklch, var(--warn), transparent 88%);
border-color: color-mix(in oklch, var(--warn), transparent 60%);
}
.hero-tile--amber .hero-num { color: oklch(0.86 0.13 80); }
.hero-tile--amber .hero-label { color: oklch(0.78 0.08 80); }
/* ---------- nav tabs ---------- */ /* ---------- nav tabs ---------- */
.nav-tab { .nav-tab {
font-size: 13px; padding: 18px 0; font-size: 13px; padding: 18px 0;
+20
View File
@@ -78,6 +78,26 @@
</p> </p>
</div> </div>
{{if and $page.CanAdmin $page.UpdateAvailable}}
<div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Agent update</div>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
Agent at <span class="mono text-ink-mid">{{$host.AgentVersion}}</span> ·
server at <span class="mono text-ink-mid">{{$page.TargetVersion}}</span>.
Pushes a self-update command; the agent re-launches into the new binary
and reconnects.
</p>
<form hx-post="/hosts/{{$host.ID}}/update" hx-swap="none">
<button class="btn btn-amber btn-block"
{{if not $page.Online}}disabled title="Agent must be online"
{{else if $page.UpdateInProgress}}disabled title="Update already in progress"
{{end}}>
Update agent
</button>
</form>
</div>
{{end}}
<div class="panel rounded-[7px] px-4 py-3.5"> <div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Restore</div> <div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Restore</div>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3"> <p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
+1 -1
View File
@@ -83,7 +83,7 @@
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute"> <div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span> <span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
<span class="text-ink-fade">·</span> <span class="text-ink-fade">·</span>
<span>agent <span class="mono text-ink-mid">{{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}}</span></span> <span>agent <span class="mono text-ink-mid">{{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}}</span>{{if $page.UpdateAvailable}} {{template "host_update_chip" $page}}{{end}}</span>
<span class="text-ink-fade">·</span> <span class="text-ink-fade">·</span>
<span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span> <span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
<span class="text-ink-fade">·</span> <span class="text-ink-fade">·</span>
+1 -1
View File
@@ -14,7 +14,7 @@
{{- end -}} {{- end -}}
</div> </div>
<div class="mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{$h.Name}}</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="mono text-ink-mid text-[12px]">{{$h.OS}}/{{$h.Arch}}{{if .UpdateAvailable}} {{template "host_update_chip" .}}{{end}}</div>
<div class="text-xs text-ink-mid"> <div class="text-xs text-ink-mid">
{{- if $h.CurrentJobID -}} {{- if $h.CurrentJobID -}}
<span class="text-accent">backup running…</span><br> <span class="text-accent">backup running…</span><br>
@@ -0,0 +1,11 @@
{{/*
host_update_chip — small amber chip rendered when the agent version
on a host is behind the server's. Expects:
.UpdateAvailable bool
.TargetVersion string
.Host store.Host (for AgentVersion)
Hidden entirely when UpdateAvailable is false.
*/}}
{{define "host_update_chip"}}
{{if .UpdateAvailable}}<span class="update-chip" title="Agent at {{.Host.AgentVersion}}; server at {{.TargetVersion}}">out of date · {{.Host.AgentVersion}} → {{.TargetVersion}}</span>{{end}}
{{end}}