feat(master): Ledger UI for Services and Agents (#18)

Restyles the master UI after the Ledger design: a sidebar with live counts,
a machine filter, a Live box and a light/dark toggle; the overview groups
services per Swarm cluster and machine with icons, search and the
Icon / Icon + text / Text switch; the agents page gets a revoke popover and
an add-agent panel with a connect step. Jobs, Job and Service pages get the
new shell and tokens only.

- fonts and Simple Icons are served by the master (no CDN)
- the overview reads agents too, to show offline banners and cluster managers
- revoking an agent also publishes the agents event
- the SSE keepalive is a named event (ping) the Live box counts
- the master passes its build version to the UI so agents on another
  version are marked old

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
2026-10-13 10:18:24 +02:00
parent 11c876af7f
commit dd2b90bece
13 changed files with 1565 additions and 189 deletions
@@ -1,6 +0,0 @@
{{define "content"}}
<h1>Agent "{{.Created.Name}}" created</h1>
<p class="warning">The token is shown only once. Save this as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</p>
<pre>{{.Snippet}}</pre>
<p><a href="/agents">Back to agents</a></p>
{{end}}
+90 -28
View File
@@ -1,38 +1,100 @@
{{define "content"}}
<h1>Agents</h1>
<form method="post" action="/agents" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label>Name <input name="name" required placeholder="node-2"></label>
<button type="submit">Add agent</button>
</form>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="hdr ruled">
<h1>Agents</h1>
<span class="sub">{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}</span>
<div class="end"><a class="btn pri" href="/agents?add=1">Add agent</a></div>
</div>
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
{{template "agents" .}}
</div>
{{end}}
{{define "agents"}}
<table>
<thead><tr><th>Name</th><th>Hostname</th><th>State</th><th>Version</th><th>Swarm manager</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{{range .Agents}}
<tr>
<td>{{.Name}}</td>
<td>{{.Hostname}}</td>
<td>{{if .RevokedAt}}<span class="status status-stopped">revoked</span>{{else if .Online}}<span class="status status-running">online</span>{{else}}<span class="status status-degraded">offline</span>{{end}}</td>
<td>{{.AgentVersion}}</td>
<td>{{if .SwarmManager}}yes{{end}}</td>
<td>{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}}</td>
<td>{{if not .RevokedAt}}
<form method="post" action="/agents/{{.ID}}/revoke" onsubmit="return confirm('Revoke {{.Name}}? It is disconnected immediately and must be added again to come back.')">
{{range .Agents}}
<div class="agent">
<div class="id"><b>{{.Name}}</b><span>{{.Hostname}}</span></div>
<div class="stc"><span class="st {{.StatusClass}}"><i></i>{{.Status}}</span></div>
<div class="ver{{if .VersionOld}} old{{end}}"><span class="k">Agent</span>{{.AgentVersion}}</div>
<div class="mgr"><span class="k">Swarm</span>{{.Role}}</div>
<div class="last"><span class="k">Last seen</span>{{if .Online}}now{{else}}{{with .LastSeenAt}}<time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}" data-rel>{{.Format "2006-01-02 15:04"}}</time>{{else}}—{{end}}{{end}}</div>
<div class="act">{{if not .RevokedAt}}
<details class="rv" data-id="{{.ID}}">
<summary class="btn sm red">Revoke</summary>
<div class="pop" role="dialog" aria-label="Confirm revoke">
<h4>Revoke {{.Name}}?</h4>
<p>Its token stops working immediately. Services on {{.Name}} keep running, but drop out of package-updater.</p>
<form method="post" action="/agents/{{.ID}}/revoke" class="acts">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit">Revoke</button>
<button type="button" class="btn sm quiet" data-close>Cancel</button>
<button type="submit" class="btn sm redfill">Revoke</button>
</form>
{{end}}</td>
</tr>
{{else}}
<tr><td colspan="7">No agents yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}</div>
</div>
{{else}}
<p class="nomatch">No agents yet.</p>
{{end}}
{{end}}
{{/* The "add agent" panel beside the list. Step 1 asks for a name, step 2 shows the one-time token, step 3 waits for the agent to connect. */}}
{{define "drawer"}}
{{if .AddStep}}
<section class="drawer" aria-label="Add agent">
<div class="dh">
<h2>Add agent</h2>
<a class="btn sm quiet x" href="/agents" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"></path></svg>
</a>
</div>
{{if eq .AddStep 1}}
<div class="steps" id="add-steps">{{template "steps" 1}}</div>
<form method="post" action="/agents" class="dform">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label class="field">Name
<input name="name" required autofocus autocomplete="off" placeholder="node-2" value="{{.FormName}}">
<small>Lowercase letters, digits and dashes. Use the machine's name.</small>
</label>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<div class="df">
<a class="btn quiet" href="/agents">Cancel</a>
<button type="submit" class="btn pri">Create agent</button>
</div>
</form>
{{else}}
<div class="steps" id="add-steps">{{template "steps" 2}}</div>
<div class="namebox"><span>Name</span><code>{{.Created.Name}}</code></div>
<div class="notice" role="note">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 1.8L15 14H1L8 1.8z"></path><path d="M8 6.5v3.5M8 12v.01"></path></svg>
<span><b>This token is shown only once.</b> Save the snippet as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</span>
</div>
<div class="code">
<div class="code-h"><span>docker-compose.yml</span><button type="button" class="btn sm" data-copy-target="snippet">Copy</button></div>
<pre id="snippet">{{.Snippet}}</pre>
</div>
<div class="secret">
<span>Token</span><code>{{middle .Token}}</code><button type="button" class="btn sm" data-copy="{{.Token}}">Copy</button>
<span>Master key</span><code>{{middle .MasterKey}}</code><button type="button" class="btn sm" data-copy="{{.MasterKey}}">Copy</button>
</div>
<div class="df" id="connect" hx-get="/fragments/agents/{{.Created.ID}}/connect" hx-trigger="load, sse:agents" hx-swap="innerHTML">
{{template "connect" .}}
</div>
{{end}}
</section>
{{end}}
{{end}}
{{/* step is the one in progress (1 to 3); 4 marks all three as done. */}}
{{define "steps"}}<span class="{{if gt . 1}}done{{else}}now{{end}}"><b>1</b>Name</span><span class="sep"></span><span class="{{if gt . 2}}done{{else if eq . 2}}now{{end}}"><b>2</b>Deploy</span><span class="sep"></span><span class="{{if gt . 3}}done{{else if eq . 3}}now{{end}}"><b>3</b>Connect</span>{{end}}
{{/* Step 3. Until the agent has connected, Cancel revokes the token that was just created. */}}
{{define "connect"}}{{if .Connected}}<span class="ok">Connected · {{.Created.Hostname}} · {{.Created.AgentVersion}}</span>
<a class="btn pri" href="/agents">Done</a>
<div id="add-steps" hx-swap-oob="innerHTML">{{template "steps" 4}}</div>
{{else}}<span><span class="spin" aria-hidden="true"></span>Waiting for {{.Created.Name}}…</span>
<form method="post" action="/agents/{{.Created.ID}}/revoke">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="btn quiet">Cancel</button>
</form>
<a class="btn pri" href="/agents">Done</a>
{{end}}{{end}}
+50 -11
View File
@@ -1,23 +1,62 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<html lang="en" class="app">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>{{.Title}} · package-updater</title>
<script>
// Sets the theme and the display mode before first paint. app.js changes them later.
(function () {
var root = document.documentElement, theme = null, display = null;
try { theme = localStorage.getItem('pu-theme'); display = localStorage.getItem('pu-display'); } catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
if (theme === 'light') root.classList.add('t-light');
root.classList.add(display === 'icon' || display === 'text' ? 'd-' + display : 'd-both');
})();
</script>
<link rel="preload" href="/static/schibsted-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/htmx-ext-sse.js"></script>
<script src="/static/app.js" defer></script>
</head>
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
<nav>
<a href="/">Services</a>
<a href="/jobs">Jobs</a>
<a href="/agents">Agents</a>
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit">Log out</button>
</form>
</nav>
<main>{{template "content" .}}</main>
<div class="shell">
<aside class="side">
<div class="brand">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M3 6h14M3 10h9M3 14h6"></path><path d="M15 16V10m-2.5 2.5L15 10l2.5 2.5"></path></svg>
package-updater
</div>
<div id="snav" hx-get="/fragments/nav?section={{.Nav.Section}}" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">{{template "nav" .}}</div>
{{block "machines" .}}{{end}}
<div class="livebox" id="live">
<b><span class="pulse" aria-hidden="true"></span>Live</b>
<span id="live-age">connecting…</span>
</div>
<div class="sfoot">
<button type="button" class="btn sm quiet" id="theme-toggle">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="5"></circle><path d="M7 2v10" ></path></svg>
Theme
</button>
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="btn sm quiet">Log out</button>
</form>
</div>
</aside>
<div class="work">
<main class="main">{{template "content" .}}</main>
{{block "drawer" .}}{{end}}
</div>
</div>
</body>
</html>{{end}}
{{define "nav"}}<nav class="snav" aria-label="Main">
<a href="/"{{if eq .Nav.Section "services"}} class="cur" aria-current="page"{{end}}>Services <span class="n">{{.Nav.Services}}</span></a>
<a href="/jobs"{{if eq .Nav.Section "jobs"}} class="cur" aria-current="page"{{end}}>Jobs</a>
<a href="/agents"{{if eq .Nav.Section "agents"}} class="cur" aria-current="page"{{end}}>Agents <span class="n">{{.Nav.AgentsOnline}}/{{.Nav.AgentsTotal}}</span></a>
</nav>{{end}}
+93 -28
View File
@@ -1,38 +1,103 @@
{{define "content"}}
<h1>Services</h1>
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services" hx-swap="innerHTML">
<div class="hdr">
<h1>Services</h1>
</div>
<div class="tools">
<label class="search">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5"></circle><path d="M10.3 10.3L14 14"></path></svg>
<input type="search" id="q" placeholder="Search services" aria-label="Search services" autocomplete="off">
</label>
<div class="end">
<span class="lbl" id="showlbl">Show</span>
<div class="seg" role="group" aria-labelledby="showlbl">
<button type="button" data-display="icon" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="2.5"></rect></svg>
Icon
</button>
<button type="button" data-display="both" aria-pressed="true">
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1" y="3" width="8" height="8" rx="2"></rect><path d="M12 7h5"></path></svg>
Icon + text
</button>
<button type="button" data-display="text" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M2 4.5h10M2 9.5h7"></path></svg>
Text
</button>
</div>
</div>
</div>
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">
{{template "services" .}}
</div>
{{end}}
{{define "services"}}
{{/* The machine filter in the sidebar. The page defines it; the services fragment replaces its links out of band. */}}
{{define "machines"}}
<div class="mblock">
<div class="slabel">Machines</div>
<nav class="mlist" id="machines" aria-label="Filter by machine">{{template "machine-links" .}}</nav>
</div>
{{end}}
{{define "machine-links"}}
<a class="cur" href="/" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
{{range .Groups}}
<section>
<h2>{{.Title}}</h2>
<table>
<thead><tr><th>Service</th><th>Image</th><th>Status</th><th>Mode</th></tr></thead>
<tbody>
{{range .Services}}
<tr>
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}{{if followsNoTag .Service}} <span class="badge">follows no tag</span>{{end}}{{if .LastPollError}} <span class="badge badge-error" title="{{.LastPollError}}">poll failed</span>{{end}}</td>
<td><code>{{shortRef .ActualRef}}</code></td>
<td class="status status-{{.Status}}">{{.Status}}</td>
<td>{{.UpdateMode}}
<form method="post" action="/services/{{.ID}}/mode" class="mode">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="return" value="overview">
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
<button type="submit">Make manual</button>
{{else}}<input type="hidden" name="mode" value="auto">
<button type="submit"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>{{if .AutoBlocked}} <span class="muted">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<a href="/"{{if .Sub}} class="sub"{{end}} data-machine="{{.Key}}">{{if .Cluster}}<span class="sw-icon" aria-hidden="true"></span>{{else}}<span class="mdot{{if .Offline}} off{{end}}" aria-hidden="true"></span>{{end}}{{.Title}} <span class="n">{{len .Services}}</span></a>
{{end}}
{{end}}
{{/* What /fragments/services returns: the groups, and the machine filter swapped in out of band. */}}
{{define "services-fragment"}}
{{template "services" .}}
<div id="machines" hx-swap-oob="innerHTML">{{template "machine-links" .}}</div>
{{end}}
{{define "services"}}
{{if .Sprite}}<svg class="sprite" aria-hidden="true">{{range .Sprite}}<symbol id="si-{{.Slug}}" viewBox="0 0 24 24"><path fill="currentColor" d="{{.Path}}"/></symbol>{{end}}</svg>{{end}}
{{range .Groups}}
<section class="grp{{if .Offline}} offline{{end}}" data-key="{{.Key}}" aria-label="{{.Title}}">
<div class="gh">
<h2{{if .TitleHint}} title="{{.TitleHint}}"{{end}}>{{.Title}}</h2>
<span class="kind">{{.Kind}}</span>
{{if .Meta}}<span class="meta">{{.Meta}}</span>{{end}}
{{if .Offline}}<span class="warn">{{.OfflineLabel}}{{with .OfflineSince}} since <time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}">{{.Format "15:04"}}</time>{{end}} · showing last known state</span>{{end}}
<span class="count">{{len .Services}}</span>
</div>
{{range .Services}}
<div class="row" data-search="{{.Search}}">
<div class="nm">
<a href="/services/{{.ID}}" title="{{.DisplayName}}" aria-label="{{.DisplayName}}">
{{if .Icon}}<span class="ico" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24"><use href="#si-{{.Icon}}"></use></svg></span>{{else}}<span class="ltr {{.LetterClass}}" aria-hidden="true">{{.Letter}}</span>{{end}}
<span class="nmt">{{.Name}}</span>
</a>
{{if .Stack}}<span class="stk">{{.Stack}}</span>{{end}}
</div>
<div class="ver" title="{{.ActualRef}}"><span class="r">{{.Repo}}</span><span class="t"><b>{{.Tag}}</b>{{.Digest}}</span></div>
<div class="stc">
<span class="st {{.StatusClass}}"><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" stroke="currentColor" stroke-width="1.5"></circle></svg>{{.StatusLabel}}</span>
</div>
<div class="flags">
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
{{if .Self}}<span class="tag f-out">Managed outside</span>{{end}}
</div>
<div class="mode">
{{if .Self}}<span class="na">n/a</span>
{{else}}<form method="post" action="/services/{{.ID}}/mode">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="return" value="overview">
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
<button type="submit" class="sw" role="switch" aria-checked="true"><i></i>Auto</button>
{{else}}<input type="hidden" name="mode" value="auto">
<button type="submit" class="sw" role="switch" aria-checked="false"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}><i></i>Manual</button>
{{if .AutoBlocked}}<span class="na">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>{{end}}
</div>
</div>
{{end}}
</section>
{{else}}
<p>No services reported yet. <a href="/agents">Add an agent</a> to get started.</p>
<p>No services reported yet. <a href="/agents?add=1">Add an agent</a> to get started.</p>
{{end}}
<p class="nomatch" id="nomatch" hidden>No services match.</p>
{{end}}