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:
@@ -4,6 +4,7 @@ import (
|
||||
stdhttp "net/http"
|
||||
|
||||
"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
|
||||
@@ -27,6 +28,8 @@ type hostView struct {
|
||||
RepoSizeBytes int64 `json:"repo_size_bytes"`
|
||||
SnapshotCount int `json:"snapshot_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
|
||||
@@ -85,6 +88,8 @@ func hostToView(h store.Host) hostView {
|
||||
RepoSizeBytes: h.RepoSizeBytes,
|
||||
SnapshotCount: h.SnapshotCount,
|
||||
OpenAlertCount: h.OpenAlertCount,
|
||||
TargetVersion: version.Version,
|
||||
UpdateAvailable: h.AgentVersion != "" && h.AgentVersion != version.Version,
|
||||
}
|
||||
if v.Tags == nil {
|
||||
v.Tags = []string{}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"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/store"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
"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
|
||||
// dumb.
|
||||
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.
|
||||
@@ -165,6 +170,10 @@ type dashboardFilter struct {
|
||||
Tag string // mirrors ActiveTag for round-trip on links
|
||||
Sort string // column key (see sortDashboard)
|
||||
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
|
||||
@@ -180,6 +189,13 @@ type dashboardHostRow struct {
|
||||
// NextRun is the next-fire time of RunAllScheduleID (when set),
|
||||
// computed server-side from its cron. nil otherwise.
|
||||
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
|
||||
@@ -255,7 +271,11 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
// calls per host — fine at fleet sizes we care about.
|
||||
rows := make([]dashboardHostRow, 0, len(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)
|
||||
if gerr != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
updatesBehind := 0
|
||||
for _, h := range allHosts {
|
||||
if h.Status == "online" && h.AgentVersion != "" && h.AgentVersion != version.Version {
|
||||
updatesBehind++
|
||||
}
|
||||
}
|
||||
|
||||
view := s.baseView(r, u)
|
||||
view.Page = dashboardPage{
|
||||
Hosts: rows,
|
||||
@@ -302,6 +329,7 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
Filter: filter,
|
||||
RefreshURL: "/?" + filter.encode(),
|
||||
SortURL: buildDashboardSortURLs(filter),
|
||||
UpdatesBehind: updatesBehind,
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
||||
slog.Error("ui: render dashboard", "err", err)
|
||||
@@ -320,6 +348,7 @@ func parseDashboardFilter(q url.Values) dashboardFilter {
|
||||
Tag: q.Get("tag"),
|
||||
Sort: q.Get("sort"),
|
||||
Dir: q.Get("dir"),
|
||||
Updates: q.Get("updates"),
|
||||
}
|
||||
if f.Sort == "" {
|
||||
f.Sort = "name"
|
||||
@@ -352,6 +381,9 @@ func (f dashboardFilter) encode() string {
|
||||
if f.Dir != "" && f.Dir != "asc" {
|
||||
v.Set("dir", f.Dir)
|
||||
}
|
||||
if f.Updates != "" {
|
||||
v.Set("updates", f.Updates)
|
||||
}
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
@@ -402,6 +434,11 @@ func filterAndSortDashboardHosts(hosts []store.Host, f dashboardFilter) []store.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if f.Updates == "behind" {
|
||||
if h.AgentVersion == "" || h.AgentVersion == version.Version {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
sortDashboardHosts(out, f.Sort, f.Dir)
|
||||
@@ -809,6 +846,20 @@ type hostChromeData struct {
|
||||
SourceGroupCount int
|
||||
ScheduleCount int
|
||||
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,
|
||||
// used for autocomplete on the host-tags edit form. Cheap query.
|
||||
KnownTags []string
|
||||
@@ -834,6 +885,14 @@ type hostChromeData struct {
|
||||
// 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 {
|
||||
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 {
|
||||
d.SourceGroupCount = len(groups)
|
||||
} else {
|
||||
@@ -972,8 +1031,10 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
|
||||
|
||||
view := s.baseView(r, u)
|
||||
view.Title = host.Name + " · restic-manager"
|
||||
chrome := s.loadHostChrome(r, *host, "snapshots", "snapshots")
|
||||
chrome.CanAdmin = u.Role == string(store.RoleAdmin)
|
||||
view.Page = hostDetailPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
|
||||
hostChromeData: chrome,
|
||||
Snapshots: shown,
|
||||
SnapshotsShown: len(shown),
|
||||
LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -104,6 +104,65 @@
|
||||
.btn-lg { font-size: 13px; padding: 9px 14px; }
|
||||
.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-tab {
|
||||
font-size: 13px; padding: 18px 0;
|
||||
|
||||
@@ -78,6 +78,26 @@
|
||||
</p>
|
||||
</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="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">
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<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="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>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
|
||||
<span class="text-ink-fade">·</span>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{{- end -}}
|
||||
</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">
|
||||
{{- if $h.CurrentJobID -}}
|
||||
<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}}
|
||||
Reference in New Issue
Block a user