Skip to content

Commit 7e7ce39

Browse files
b0bbywanclaude
andcommitted
feat(ui): add upgrade status badge in the header
Show a status badge next to the title/hostname reflecting the detector result from GET /upgrade: "à jour", "maj dispo" (update available), or "vérifier" when no detection has run. The last-check time (checked_at, passed through verbatim by the backend) is surfaced in the tooltip. Clicking re-runs detection (POST /upgrade/check); the fresh result lands via a new section-upgrade SSE fragment, wired from upgrade.info. The SSE connection is hoisted to <body> so the header badge receives updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0798fb7 commit 7e7ce39

9 files changed

Lines changed: 194 additions & 6 deletions

File tree

ui/client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,17 @@ func (c *APIClient) GetServices() ([]ServiceView, error) {
226226
return convertServices(raw), nil
227227
}
228228

229+
// GetUpgrade returns the detector status from /upgrade. The endpoint yields
230+
// JSON null when no detection has run; that decodes to a zero status (Known()
231+
// is then false), which the badge renders as a neutral "check" prompt.
232+
func (c *APIClient) GetUpgrade() (*UpgradeStatus, error) {
233+
var v UpgradeStatus
234+
if err := c.get("/upgrade", &v); err != nil {
235+
return nil, err
236+
}
237+
return &v, nil
238+
}
239+
229240
func convertServices(raw []Service) []ServiceView {
230241
views := make([]ServiceView, 0, len(raw))
231242
for _, s := range raw {

ui/handler.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
134134
}
135135
}
136136

137+
if serverInfo.Backends.Upgrade {
138+
logger.Debug("[ui] → API GET /upgrade")
139+
if upgradeStatus, err := h.client.GetUpgrade(); err == nil {
140+
data.Upgrade = upgradeStatus
141+
logger.Debug("[ui] ← API /upgrade: available=%v", upgradeStatus.UpgradeAvailable)
142+
} else {
143+
logger.Warn("[ui] Failed to fetch upgrade status: %v", err)
144+
}
145+
}
146+
137147
// Render template
138148
if err := h.tmpl.ExecuteTemplate(w, "dashboard", data); err != nil {
139149
logger.Error("[ui] Template execution failed: %v", err)
@@ -217,6 +227,25 @@ func (h *Handler) BluetoothSection(w http.ResponseWriter, r *http.Request) {
217227
}
218228
}
219229

230+
// UpgradeSection renders just the upgrade badge (for HTMX updates)
231+
func (h *Handler) UpgradeSection(w http.ResponseWriter, r *http.Request) {
232+
logger.Debug("[ui] %s %s (HTMX section refresh)", r.Method, r.URL.Path)
233+
234+
logger.Debug("[ui] → API GET /upgrade")
235+
status, err := h.client.GetUpgrade()
236+
if err != nil {
237+
logger.Error("[ui] Failed to fetch upgrade status: %v", err)
238+
http.Error(w, "Failed to load upgrade status", http.StatusInternalServerError)
239+
return
240+
}
241+
logger.Debug("[ui] ← API /upgrade: available=%v", status.UpgradeAvailable)
242+
243+
if err := h.tmpl.ExecuteTemplate(w, "section-upgrade", status); err != nil {
244+
logger.Error("[ui] Template execution failed: %v", err)
245+
http.Error(w, "Failed to render section", http.StatusInternalServerError)
246+
}
247+
}
248+
220249
// sseSection maps an event type to the SSE event name and the section data fetcher.
221250
type sseSection struct {
222251
name string
@@ -243,11 +272,27 @@ func fetchBluetooth(h *Handler) (string, any, error) {
243272
return "section-bluetooth", status, err
244273
}
245274

275+
func fetchUpgrade(h *Handler) (string, any, error) {
276+
status, err := h.client.GetUpgrade()
277+
return "section-upgrade", status, err
278+
}
279+
280+
func fetchUpgradeRing(h *Handler) (string, any, error) {
281+
status, err := h.client.GetUpgrade()
282+
var run *UpgradeRun
283+
if status != nil {
284+
run = status.Run
285+
}
286+
return "upgrade-ring", run, err
287+
}
288+
246289
var (
247-
mprisSection = &sseSection{name: "section-mpris", fetchFn: fetchMPRIS}
248-
audioSection = &sseSection{name: "section-audio", fetchFn: fetchAudio}
249-
systemdSection = &sseSection{name: "section-systemd", fetchFn: fetchSystemd}
250-
bluetoothSection = &sseSection{name: "section-bluetooth", fetchFn: fetchBluetooth}
290+
mprisSection = &sseSection{name: "section-mpris", fetchFn: fetchMPRIS}
291+
audioSection = &sseSection{name: "section-audio", fetchFn: fetchAudio}
292+
systemdSection = &sseSection{name: "section-systemd", fetchFn: fetchSystemd}
293+
bluetoothSection = &sseSection{name: "section-bluetooth", fetchFn: fetchBluetooth}
294+
upgradeSection = &sseSection{name: "section-upgrade", fetchFn: fetchUpgrade}
295+
upgradeRingSection = &sseSection{name: "upgrade-progress", fetchFn: fetchUpgradeRing}
251296

252297
eventToSection = map[string]*sseSection{
253298
events.TypePlayerUpdated: mprisSection,
@@ -266,6 +311,8 @@ var (
266311

267312
events.TypeServiceUpdated: systemdSection,
268313
events.TypeBluetoothUpdated: bluetoothSection,
314+
events.TypeUpgradeInfo: upgradeSection,
315+
events.TypeUpgradeProgress: upgradeRingSection,
269316
}
270317
)
271318

ui/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
2424
mux.HandleFunc("/ui/sections/audio", h.AudioSection)
2525
mux.HandleFunc("/ui/sections/systemd", h.SystemdSection)
2626
mux.HandleFunc("/ui/sections/bluetooth", h.BluetoothSection)
27+
mux.HandleFunc("/ui/sections/upgrade", h.UpgradeSection)
2728

2829
// Static assets with ETag support (embed.FS has no useful Last-Modified)
2930
mux.Handle("/ui/static/", etagHandler(http.StripPrefix("/ui/", http.FileServer(http.FS(staticFS)))))

ui/styles/input.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ button.htmx-request {
5858
vertical-align: middle;
5959
}
6060

61+
/* Upgrade progress ring (circumference 100; fill via stroke-dashoffset) */
62+
.upgrade-ring {
63+
transform: rotate(-90deg);
64+
}
65+
.upgrade-ring-track {
66+
fill: none;
67+
stroke: currentColor;
68+
stroke-opacity: 0.2;
69+
stroke-width: 4;
70+
}
71+
.upgrade-ring-fill {
72+
fill: none;
73+
stroke: currentColor;
74+
stroke-width: 4;
75+
stroke-dasharray: 100;
76+
stroke-linecap: round;
77+
}
78+
6179
/* Volume & seek sliders */
6280
.volume-slider,
6381
.seek-slider {

ui/templates/base.gohtml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
</head>
2222

23-
<body class="min-h-screen bg-zinc-900 text-zinc-100">
23+
<body class="min-h-screen bg-zinc-900 text-zinc-100" hx-ext="sse" sse-connect="/ui/events">
2424
<!-- Responsive sticky header -->
2525
<header class="sticky top-0 z-10 border-b border-zinc-800 bg-zinc-900/95 backdrop-blur">
2626
<div class="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
@@ -39,6 +39,11 @@
3939
</p>
4040
{{ end }}
4141
</div>
42+
{{ if and .ServerInfo .ServerInfo.Backends.Upgrade }}
43+
<div sse-swap="section-upgrade" hx-swap="innerHTML">
44+
{{ template "section-upgrade" .Upgrade }}
45+
</div>
46+
{{ end }}
4247
</div>
4348

4449
<!-- Centre slot: PWA promo + transient toasts, naturally centred between title and right block -->

ui/templates/components/icons.gohtml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@
3737
{{ define "icon-repeat" }}<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>{{ end }}
3838

3939
{{ define "icon-repeat-one" }}<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/></svg>{{ end }}
40+
41+
{{ define "icon-check" }}<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{ end }}
42+
43+
{{ define "icon-arrow-up" }}<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>{{ end }}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{{/*
2+
Upgrade status badge for the header. Data is *ui.UpgradeStatus (may be nil).
3+
State is icon + colour, no text label. Click installs (POST /upgrade/start,
4+
confirmed) when available, else re-runs detection (POST /upgrade/check).
5+
While running, the ring is its own sse-swap target updated by upgrade-ring.
6+
*/}}
7+
{{ define "section-upgrade" }}
8+
{{- $checked := .CheckedAtLabel -}}
9+
{{- if .Running -}}
10+
<span class="inline-flex items-center rounded-full bg-gold/10 p-1.5 text-gold [&_svg]:h-4 [&_svg]:w-4"
11+
title="Upgrading…{{ with .Run.Step }} {{ . }}{{ end }}"
12+
sse-swap="upgrade-progress" hx-swap="innerHTML">
13+
{{ template "upgrade-ring" .Run }}
14+
</span>
15+
{{- else if .Known -}}
16+
{{- if .UpgradeAvailable -}}
17+
<button hx-post="/upgrade/start" hx-swap="none"
18+
hx-confirm="Upgrade to {{ .Latest }}?"
19+
class="inline-flex items-center rounded-full bg-gold/10 p-1.5 text-gold transition hover:bg-gold/20 [&_svg]:h-4 [&_svg]:w-4"
20+
title="Upgrade available: {{ .Latest }}{{ if $checked }} · checked {{ $checked }}{{ end }}">
21+
{{ template "icon-arrow-up" }}
22+
</button>
23+
{{- else -}}
24+
<button hx-post="/upgrade/check" hx-swap="none"
25+
class="inline-flex items-center rounded-full bg-zinc-700/50 p-1.5 text-zinc-400 transition hover:bg-zinc-700 [&_svg]:h-4 [&_svg]:w-4"
26+
title="Up to date{{ if $checked }} · checked {{ $checked }}{{ end }}">
27+
{{ template "icon-check" }}
28+
</button>
29+
{{- end -}}
30+
{{- else -}}
31+
<button hx-post="/upgrade/check" hx-swap="none"
32+
class="inline-flex items-center rounded-full bg-zinc-700/50 p-1.5 text-zinc-400 transition hover:bg-zinc-700 [&_svg]:h-4 [&_svg]:w-4"
33+
title="Check for upgrades">
34+
{{ template "icon-rotate-cw" }}
35+
</button>
36+
{{- end -}}
37+
{{ end }}
38+
39+
{{/* Progress ring; data is *ui.UpgradeRun (may be nil). Spinner until a percent is streamed. */}}
40+
{{ define "upgrade-ring" }}
41+
{{- if .HasPercent -}}
42+
<svg class="upgrade-ring" viewBox="0 0 36 36">
43+
<circle class="upgrade-ring-track" cx="18" cy="18" r="15.915"/>
44+
<circle class="upgrade-ring-fill" cx="18" cy="18" r="15.915" stroke-dashoffset="{{ .RingOffset }}"/>
45+
</svg>
46+
{{- else -}}
47+
<span class="spinner"></span>
48+
{{- end -}}
49+
{{ end }}

ui/templates/pages/dashboard.gohtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{{- $g1 := or .ServerInfo.Backends.Bluetooth .ServerInfo.Backends.PulseAudio -}}
33
{{- $g2 := .ServerInfo.Backends.MPRIS -}}
44
{{- $g3 := .ServerInfo.Backends.Systemd -}}
5-
<div hx-ext="sse" sse-connect="/ui/events" class="grid gap-4{{ if or (and $g1 $g2) (and $g1 $g3) (and $g2 $g3) }} sm:grid-cols-2{{ end }}{{ if and $g1 $g2 $g3 }} lg:grid-cols-3{{ end }}">
5+
<div class="grid gap-4{{ if or (and $g1 $g2) (and $g1 $g3) (and $g2 $g3) }} sm:grid-cols-2{{ end }}{{ if and $g1 $g2 $g3 }} lg:grid-cols-3{{ end }}">
66
{{ if or .ServerInfo.Backends.Bluetooth .ServerInfo.Backends.PulseAudio }}
77
<div class="min-w-0 flex flex-col gap-4">
88
{{ if .ServerInfo.Backends.Bluetooth }}

ui/types.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,61 @@ type Backends struct {
3030
Power bool `json:"power"`
3131
PulseAudio bool `json:"pulseaudio"`
3232
Systemd bool `json:"systemd"`
33+
Upgrade bool `json:"upgrade"`
3334
Zeroconf bool `json:"zeroconf"`
3435
}
3536

37+
// UpgradeStatus is the GET /upgrade payload: the detector's contract fields plus
38+
// the live run state. Free detector fields ride under "extra" server-side and
39+
// are unused here. Latest is empty when no detection has run yet.
40+
type UpgradeStatus struct {
41+
Current string `json:"current"`
42+
Latest string `json:"latest"`
43+
UpgradeAvailable bool `json:"upgrade_available"`
44+
CheckedAt time.Time `json:"checked_at"`
45+
Run *UpgradeRun `json:"run"`
46+
}
47+
48+
// UpgradeRun is the live progress of an in-flight upgrade; nil unless one runs.
49+
// Percent is nil until the script streams it (e.g. after an odio-api restart
50+
// mid-run), which the badge shows as an indeterminate ring.
51+
type UpgradeRun struct {
52+
State string `json:"state"`
53+
Percent *int `json:"percent"`
54+
Step string `json:"step"`
55+
}
56+
57+
func (r *UpgradeRun) HasPercent() bool { return r != nil && r.Percent != nil }
58+
59+
func (r *UpgradeRun) PercentValue() int {
60+
if !r.HasPercent() {
61+
return 0
62+
}
63+
return *r.Percent
64+
}
65+
66+
// RingOffset is the stroke-dashoffset for a circumference-100 ring.
67+
func (r *UpgradeRun) RingOffset() int { return 100 - r.PercentValue() }
68+
69+
// Known reports whether a detection result is available to display.
70+
func (u *UpgradeStatus) Known() bool {
71+
return u != nil && u.Latest != ""
72+
}
73+
74+
// Running reports whether an upgrade is in flight.
75+
func (u *UpgradeStatus) Running() bool {
76+
return u != nil && u.Run != nil && u.Run.State == "running"
77+
}
78+
79+
// CheckedAtLabel is the last-check time for the badge tooltip, in local time,
80+
// or empty when no detection has run.
81+
func (u *UpgradeStatus) CheckedAtLabel() string {
82+
if u == nil || u.CheckedAt.IsZero() {
83+
return ""
84+
}
85+
return u.CheckedAt.Local().Format("02 Jan 2006, 15:04")
86+
}
87+
3688
// PlayerCapabilities represents what transport actions a player supports
3789
type PlayerCapabilities struct {
3890
CanPlay bool `json:"can_play"`
@@ -144,6 +196,7 @@ type DashboardView struct {
144196
AudioData *AudioData
145197
Services []ServiceView
146198
Bluetooth *BluetoothView
199+
Upgrade *UpgradeStatus
147200
}
148201

149202
// PlayerView is a view-optimized version of Player for templates

0 commit comments

Comments
 (0)