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"
|
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{}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user