Compare commits

...
Author SHA1 Message Date
shcizo ec28d1e027 feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s
## Summary

- Rebuilds the master's **Services** and **Agents** pages per the "Ledger" design: sidebar shell with live nav counts, machine filter and Live indicator; dark/light theme (follows the OS, toggle overrides); embedded Schibsted Grotesk and JetBrains Mono (fontsource 5.3.0, OFL).
- Services: Swarm grouped per cluster with its machines under it, offline banner ("Agent offline since HH:MM", browser-local), Simple Icons derived from the image ref (letter circle fallback), existing badges and mode switch restyled, client-side search, machine filter and Icon / Icon + text / Text toggle.
- Agents: Online / Offline / Never connected / Revoked, master version in the header with outdated agent versions marked, revoke via popover instead of `confirm()`, "Add agent" as a three-step panel that completes when the agent says hello (SSE `agents`).
- Store: migration `0005_agent_cluster.sql` persists each agent's `swarm_cluster_id` (needed to list a cluster's managers and its offline state after a master restart).
- SSE keepalive is now a named `ping` event so the Live indicator also shows the connection is alive. `revokeAgent` now publishes `agents`.

Closes #18

## Notes

- Workers never report a cluster id (Docker only sets `Swarm.Cluster` on managers), so their Swarm role shows "—". Fixing that needs a new hello field.
- Master binary grows by about 2.16 MB, almost all Simple Icons 16.35.0 path data (gzipped JSON, regenerated with `go generate ./internal/master/web/icons`). The agent binary does not import it.
- Jobs, Job and Service detail pages only get the new shell; their own artboards are out of scope, as are Update buttons, Update all, header stats and the drift badge.
- Unchanged: CSRF on every POST, `Cache-Control: no-store` on the token response, 401 + `HX-Redirect` without a session, no `template.HTML` on agent- or user-controlled values, no Node build step.

## Test plan

- [x] `go test ./...`, `go test -race ./internal/master/...`
- [x] `go test -tags integration ./internal/master/store/` against Postgres 17
- [x] `docker build`
- [x] Browser check on fake data: both themes, 1440 px and 390 px, add-agent flow through connect
- [ ] Services grouping and icons against real stacks and a real Swarm cluster
- [ ] "Agent offline since" after stopping an agent
- [ ] Theme toggle, search and machine filter across reloads and mode changes
- [ ] Add agent end to end with a real agent, Copy buttons
- [ ] Phone at 390 px

https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
Reviewed-on: #29
2026-10-13 09:20:18 +00:00
29 changed files with 2140 additions and 199 deletions
+3
View File
@@ -40,6 +40,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
- `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them
- `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons`
- `internal/master/web`, `webauth` — HTMX UI behind OIDC login, SSE signals
- `internal/master/events` — in-process publish/subscribe for UI refresh signals
- `internal/master/model` — domain types shared by the above
@@ -162,6 +163,8 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- **Merge lock order is agent, then service rows by id** (as in inventory writes), and the
duplicate row is deleted before the old row is updated, because `location_key` is unique
and not deferrable. A removal racing a merge can be lost; the next snapshot fixes it.
- **The SSE stream sends a named `ping` event every 25 s**; the sidebar Live indicator relies on it.
- **Agents persist `swarm_cluster_id`.** Only managers report it, so workers show no role.
## References
+1 -1
View File
@@ -117,7 +117,7 @@ func run() error {
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
PollInterval: cfg.PollInterval,
PollInterval: cfg.PollInterval, Version: version,
}, logger)
if err != nil {
return err
+6
View File
@@ -33,6 +33,12 @@ func Parse(ref string) Ref {
return r
}
// IsRegistryHost reports whether the first path segment of a repository names
// a registry: it contains a "." or ":" or is "localhost".
func IsRegistryHost(segment string) bool {
return strings.ContainsAny(segment, ".:") || segment == "localhost"
}
// String reassembles the reference as repo[:tag][@digest].
func (r Ref) String() string {
s := r.Repo
+20
View File
@@ -29,6 +29,26 @@ func TestParse(t *testing.T) {
}
}
func TestIsRegistryHost(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"lscr.io", true},
{"localhost:5000", true},
{"localhost", true},
{"registry:5000", true},
{"gitea", false},
{"library", false},
{"", false},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
assert.Equal(t, c.want, IsRegistryHost(c.in))
})
}
}
func TestIsDigest(t *testing.T) {
cases := []struct {
in string
+3
View File
@@ -29,6 +29,9 @@ type Agent struct {
Hostname string
AgentVersion string
SwarmManager bool
// SwarmClusterID is the Swarm cluster the agent's daemon was in when it last
// connected; "" outside a swarm.
SwarmClusterID string
LastSeenAt *time.Time
RevokedAt *time.Time
CreatedAt time.Time
+4 -4
View File
@@ -13,11 +13,11 @@ import (
"github.com/shcizo/package-updater/internal/protocol"
)
const agentColumns = `id, name, hostname, agent_version, swarm_manager, last_seen_at, revoked_at, created_at`
const agentColumns = `id, name, hostname, agent_version, swarm_manager, swarm_cluster_id, last_seen_at, revoked_at, created_at`
func scanAgent(row pgx.Row) (model.Agent, error) {
var a model.Agent
err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt)
err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.SwarmClusterID, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return model.Agent{}, model.ErrNotFound
}
@@ -101,8 +101,8 @@ func (s *Store) RevokeAgent(ctx context.Context, id int64, at time.Time) ([]int6
// RecordHello stores what the agent reported about itself on connect.
func (s *Store) RecordHello(ctx context.Context, id int64, h protocol.Hello, at time.Time) error {
if _, err := s.pool.Exec(ctx,
`UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, last_seen_at = $5 WHERE id = $1`,
id, h.Hostname, h.AgentVersion, h.SwarmManager, at); err != nil {
`UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, swarm_cluster_id = $6, last_seen_at = $5 WHERE id = $1`,
id, h.Hostname, h.AgentVersion, h.SwarmManager, at, h.SwarmClusterID); err != nil {
return fmt.Errorf("record hello: %w", err)
}
return nil
@@ -80,6 +80,7 @@ func TestAgents_RecordHelloAndMarkSeen(t *testing.T) {
assert.Equal(t, "swarm-mgr", agents[0].Hostname)
assert.Equal(t, "v1", agents[0].AgentVersion)
assert.True(t, agents[0].SwarmManager)
assert.Equal(t, "c1", agents[0].SwarmClusterID)
require.NotNil(t, agents[0].LastSeenAt)
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
}
@@ -0,0 +1 @@
ALTER TABLE agents ADD COLUMN swarm_cluster_id text NOT NULL DEFAULT '';
@@ -0,0 +1,30 @@
# CC0 1.0 Universal
## Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”).
Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following:
1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
2. moral rights retained by the original author(s) and/or performer(s);
3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work;
4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below;
5. rights protecting the extraction, dissemination, use and reuse of data in a Work;
6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose.
4. Limitations and Disclaimers.
1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
For more information, please see <https://creativecommons.org/publicdomain/zero/1.0>.
+128
View File
@@ -0,0 +1,128 @@
// Command gen builds simple-icons.json.gz, the icon data the icons package
// embeds. It downloads the pinned simple-icons npm tarball, checks its
// sha256 and writes a gzipped JSON object {slug: svg path data}. The output
// is deterministic. It writes into the current directory; run it with
//
// go generate ./internal/master/web/icons
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"regexp"
"strings"
)
const (
tarballURL = "https://registry.npmjs.org/simple-icons/-/simple-icons-16.35.0.tgz"
tarballSHA256 = "20ebead4f6bc93464812a5e260d19fc4d84772e5eeff69a6fbea263ca2441c5f"
)
var pathData = regexp.MustCompile(`<path d="([^"]+)"`)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "gen:", err)
os.Exit(1)
}
}
func run() error {
tgz, err := download(tarballURL)
if err != nil {
return err
}
if sum := sha256.Sum256(tgz); hex.EncodeToString(sum[:]) != tarballSHA256 {
return fmt.Errorf("sha256 mismatch for %s: got %x, want %s", tarballURL, sum, tarballSHA256)
}
icons, license, err := extract(tgz)
if err != nil {
return err
}
var js bytes.Buffer
enc := json.NewEncoder(&js)
enc.SetEscapeHTML(false)
if err := enc.Encode(icons); err != nil { // map keys are written sorted
return err
}
var gz bytes.Buffer
zw, err := gzip.NewWriterLevel(&gz, gzip.BestCompression)
if err != nil {
return err
}
if _, err := zw.Write(js.Bytes()); err != nil { // the header has no name and a zero mtime
return err
}
if err := zw.Close(); err != nil {
return err
}
if err := os.WriteFile("simple-icons.json.gz", gz.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile("LICENSE-simple-icons.md", license, 0o644); err != nil {
return err
}
fmt.Printf("wrote %d icons\n", len(icons))
return nil
}
func download(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
return io.ReadAll(resp.Body)
}
// extract returns the path data of every package/icons/*.svg by slug, and
// package/LICENSE.md.
func extract(tgz []byte) (icons map[string]string, license []byte, err error) {
zr, err := gzip.NewReader(bytes.NewReader(tgz))
if err != nil {
return nil, nil, err
}
tr := tar.NewReader(zr)
icons = map[string]string{}
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, nil, err
}
switch {
case h.Name == "package/LICENSE.md":
if license, err = io.ReadAll(tr); err != nil {
return nil, nil, err
}
case path.Dir(h.Name) == "package/icons" && strings.HasSuffix(h.Name, ".svg"):
svg, err := io.ReadAll(tr)
if err != nil {
return nil, nil, err
}
m := pathData.FindAllSubmatch(svg, -1)
if len(m) != 1 {
return nil, nil, fmt.Errorf("%s: want exactly one <path>, found %d", h.Name, len(m))
}
icons[strings.TrimSuffix(path.Base(h.Name), ".svg")] = string(m[0][1])
}
}
if license == nil || len(icons) == 0 {
return nil, nil, fmt.Errorf("tarball has no LICENSE.md or no icons")
}
return icons, license, nil
}
+109
View File
@@ -0,0 +1,109 @@
// Package icons maps container image references to brand icons from the
// Simple Icons set (CC0, see LICENSE-simple-icons.md). Only the master's UI
// imports it, so the agent binary does not carry the data.
package icons
import (
"bytes"
"compress/gzip"
_ "embed"
"encoding/json"
"strings"
"sync"
"github.com/shcizo/package-updater/internal/imageref"
)
//go:generate go run ./gen
//go:embed simple-icons.json.gz
var data []byte
var (
once sync.Once
paths map[string]string
)
// aliases maps a normalised image name to the Simple Icons slug that differs
// from it.
var aliases = map[string]string{
"postgres": "postgresql",
"goauthentik": "authentik",
"traefik": "traefikproxy",
"node": "nodedotjs",
"mongo": "mongodb",
}
// skipNamespaces are registry namespaces that name a publisher, not a product.
var skipNamespaces = map[string]bool{"library": true, "linuxserver": true, "bitnami": true, "hotio": true}
// suffixes are stripped from the last path segment to find the product name
// ("authentik-server" is authentik).
var suffixes = []string{"-server", "-web", "-api", "-worker", "-agent"}
func load() {
once.Do(func() {
paths = map[string]string{}
zr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return
}
_ = json.NewDecoder(zr).Decode(&paths)
})
}
// Path returns the SVG path data (24x24 viewBox) of the icon with this Simple
// Icons slug.
func Path(slug string) (string, bool) {
load()
p, ok := paths[slug]
return p, ok
}
// Count returns how many icons are embedded.
func Count() int {
load()
return len(paths)
}
// ForImage returns the slug of the icon that best matches the image
// reference, or "" when there is none. It tries the last path segment, that
// segment without a role suffix, and the namespace, in that order.
func ForImage(ref string) string {
segs := strings.Split(imageref.Parse(ref).Repo, "/")
if len(segs) > 1 && imageref.IsRegistryHost(segs[0]) {
segs = segs[1:]
}
last := segs[len(segs)-1]
candidates := []string{last}
for _, s := range suffixes {
if base, ok := strings.CutSuffix(last, s); ok && base != "" {
candidates = append(candidates, base)
break
}
}
if len(segs) > 1 && !skipNamespaces[segs[len(segs)-2]] {
candidates = append(candidates, segs[len(segs)-2])
}
for _, c := range candidates {
slug := normalise(c)
if a, ok := aliases[slug]; ok {
slug = a
}
if _, ok := Path(slug); ok {
return slug
}
}
return ""
}
// normalise lowercases s and keeps only a-z and 0-9, the form of Simple Icons slugs.
func normalise(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
+46
View File
@@ -0,0 +1,46 @@
package icons
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestForImage(t *testing.T) {
cases := []struct {
ref, want string
}{
{"gitea/gitea:1.27.3", "gitea"},
{"ghcr.io/goauthentik/server:2026.8.2", "authentik"},
{"postgres:17.10", "postgresql"},
{"grafana/loki:3.4.2", "grafana"},
{"docker.n8n.io/n8nio/n8n:1.112.4", "n8n"},
{"lscr.io/linuxserver/prowlarr:latest", ""},
{"localhost:5000/foo:v1", ""},
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", ""},
}
for _, c := range cases {
t.Run(c.ref, func(t *testing.T) {
assert.Equal(t, c.want, ForImage(c.ref))
})
}
}
// safePath is a strict allowlist for what an embedded path may contain, since
// the paths are written into the page's HTML.
var safePath = regexp.MustCompile(`^[MmLlHhVvCcSsQqTtAaZz0-9.,\- e]+$`)
func TestEmbeddedSet(t *testing.T) {
assert.Greater(t, Count(), 3000)
for slug, p := range paths {
if !safePath.MatchString(p) {
t.Errorf("icon %q has unexpected characters in its path", slug)
}
}
p, ok := Path("gitea")
assert.True(t, ok)
assert.NotEmpty(t, p)
_, ok = Path("no-such-icon")
assert.False(t, ok)
}
Binary file not shown.
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"bufio"
"context"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvents_SendsPingWhenIdle(t *testing.T) {
old := pingInterval
pingInterval = 20 * time.Millisecond
t.Cleanup(func() { pingInterval = old })
srv, _, _, _ := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
require.NoError(t, err)
if strings.HasPrefix(line, "event:") {
assert.Equal(t, "event: ping\n", line, "the Live box counts a named event; a comment would not reach the browser's listeners")
return
}
}
}
func TestCreateAgent_ValidationShowsInsideTheAddPanel(t *testing.T) {
srv, _, _, _ := newTestServer(t)
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-2"}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.Contains(t, string(body), `class="drawer"`)
assert.Contains(t, string(body), `value="node-2"`, "the typed name is kept")
assert.NotContains(t, string(body), "pua_", "no token is shown")
}
func TestNavFragment(t *testing.T) {
srv, _, _, _ := newTestServer(t)
cases := []struct {
query, want, notWant string
}{
{"?section=agents", `<a href="/agents" class="cur" aria-current="page">Agents <span class="n">1/3</span>`, `<a href="/" class="cur"`},
{"?section=services", `<a href="/" class="cur" aria-current="page">Services <span class="n">4</span>`, `<a href="/agents" class="cur"`},
{"?section=bogus", `<a href="/jobs">Jobs</a>`, `class="cur"`},
}
for _, c := range cases {
t.Run(c.query, func(t *testing.T) {
code, body := get(t, srv, "/fragments/nav"+c.query)
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, c.want)
assert.NotContains(t, body, c.notWant)
})
}
_, body := get(t, srv, "/agents")
assert.Contains(t, body, `hx-get="/fragments/nav?section=agents"`, "every page refreshes the sidebar on services and agents events")
assert.Contains(t, body, `hx-trigger="sse:services, sse:agents"`)
}
func TestAgentConnectFragment(t *testing.T) {
cases := []struct {
name string
path string
online bool
wantCode int
want []string
notWant []string
}{
{"never connected waits", "/fragments/agents/5/connect", false, http.StatusOK, []string{"Waiting for fresh", `action="/agents/5/revoke"`}, []string{"Connected", "add-steps"}},
{"online is connected", "/fragments/agents/5/connect", true, http.StatusOK, []string{"Connected · ", `id="add-steps" hx-swap-oob="innerHTML"`}, []string{"Waiting", "revoke"}},
{"seen before is connected", "/fragments/agents/3/connect", false, http.StatusOK, []string{"Connected · node-3.lan · v1.3.2"}, []string{"Waiting"}},
{"unknown", "/fragments/agents/99/connect", false, http.StatusNotFound, nil, nil},
{"revoked", "/fragments/agents/4/connect", false, http.StatusNotFound, nil, nil},
{"bad id", "/fragments/agents/abc/connect", false, http.StatusBadRequest, nil, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv, _, hub, _, _ := newTestEnv(t)
hub.mu.Lock()
hub.online[5] = c.online
hub.mu.Unlock()
code, body := get(t, srv, c.path)
assert.Equal(t, c.wantCode, code)
for _, w := range c.want {
assert.Contains(t, body, w)
}
for _, w := range c.notWant {
assert.NotContains(t, body, w)
}
assert.NotContains(t, body, "pua_", "the token is never sent again")
})
}
}
func TestStatic_FontsAndAppJS(t *testing.T) {
srv, _, _, _ := newTestServer(t)
for _, path := range []string{
"/static/app.js", "/static/app.css",
"/static/schibsted-grotesk-latin-wght-normal.woff2", "/static/jetbrains-mono-latin-wght-normal.woff2",
"/static/OFL-schibsted-grotesk.txt", "/static/OFL-jetbrains-mono.txt",
} {
code, _ := get(t, srv, path)
assert.Equal(t, http.StatusOK, code, path)
}
_, css := get(t, srv, "/static/app.css")
assert.NotContains(t, css, "fonts.googleapis.com", "fonts are served by the master")
assert.Contains(t, css, "format('woff2')")
_, page := get(t, srv, "/")
assert.NotContains(t, page, "fonts.googleapis.com")
assert.Contains(t, page, `src="/static/app.js"`)
}
+191 -26
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -17,6 +18,7 @@ import (
"github.com/shcizo/package-updater/internal/agenttoken"
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/master/webauth"
"github.com/shcizo/package-updater/internal/protocol"
)
@@ -87,12 +89,14 @@ type Broker interface {
Publish(t events.Topic)
}
// Config holds values shown in the "add agent" snippet and on the service page.
// Config holds values shown in the "add agent" snippet, on the agents page and
// on the service page.
type Config struct {
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
PollInterval time.Duration // shown as "every <interval>" on the service page
Version string // the master's build version; agents on another version are marked old
}
// Server renders the UI.
@@ -110,9 +114,9 @@ type Server struct {
// New parses the templates. Each page is its own template set so every page
// can define its own "content" block.
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", page, err)
@@ -142,20 +146,57 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
route("GET /fragments/jobs/{id}", s.jobFragment)
route("GET /agents", s.agents)
route("GET /fragments/agents", s.agentsFragment)
route("GET /fragments/agents/{id}/connect", s.agentConnectFragment)
route("GET /fragments/nav", s.navFragment)
route("POST /agents", s.createAgent)
route("POST /agents/{id}/revoke", s.revokeAgent)
route("GET /events", s.events)
}
// navData is what the sidebar shows on every page.
type navData struct {
Section string // services, jobs or agents; "" for none
Services int
AgentsOnline int
AgentsTotal int // agents that are not revoked
}
// spriteIcon is one icon of the sprite the services fragment carries.
type spriteIcon struct {
Slug string
Path string
}
// sections maps a page to the sidebar entry that is current on it.
var sections = map[string]string{
"overview.html": "services", "service.html": "services",
"jobs.html": "jobs", "job.html": "jobs",
"agents.html": "agents",
}
type pageData struct {
Title string
CSRF string
Nav navData
Groups []serviceGroup
// ServiceCount is how many services the groups hold.
ServiceCount int
// Sprite holds the icons the groups use, so the page carries only those.
Sprite []spriteIcon
Agents []agentRow
Error string
Created model.Agent
Snippet string
// AddStep is the step of the "add agent" panel: 0 closed, 1 name,
// 2 deploy (Created, Token, MasterKey and Snippet are set).
AddStep int
FormName string // what was typed in step 1
Token string
MasterKey string
Connected bool // the agent Created has connected
MasterVersion string // the master's build version; "" if unknown
Service model.Service
ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can
@@ -178,6 +219,58 @@ func (s *Server) base(r *http.Request, title string) pageData {
return pageData{Title: title, CSRF: sess.CSRFToken}
}
// page renders a full page: the sidebar numbers are looked up and put in data.
func (s *Server) page(w http.ResponseWriter, r *http.Request, status int, page string, data pageData) {
nav, err := s.navData(r.Context(), sections[page])
if err != nil {
s.fail(w, err)
return
}
data.Nav = nav
s.render(w, status, page, "layout", data)
}
// navData counts what the sidebar shows next to its entries.
func (s *Server) navData(ctx context.Context, section string) (navData, error) {
nav := navData{Section: section}
services, err := s.store.ListServices(ctx)
if err != nil {
return nav, err
}
nav.Services = len(services)
agents, err := s.store.ListAgents(ctx)
if err != nil {
return nav, err
}
for _, a := range agents {
if a.RevokedAt != nil {
continue
}
nav.AgentsTotal++
if s.hub.Online(a.ID) {
nav.AgentsOnline++
}
}
return nav, nil
}
// navFragment renders the sidebar entries. The page asks for it with its own
// section so the current entry stays marked.
func (s *Server) navFragment(w http.ResponseWriter, r *http.Request) {
section := r.URL.Query().Get("section")
if section != "services" && section != "jobs" && section != "agents" {
section = ""
}
nav, err := s.navData(r.Context(), section)
if err != nil {
s.fail(w, err)
return
}
data := s.base(r, "")
data.Nav = nav
s.render(w, http.StatusOK, "overview.html", "nav", data)
}
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
@@ -192,17 +285,41 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) {
if err != nil {
return data, err
}
data.Groups = groupServices(services, s.autoBlocked)
agents, err := s.store.ListAgents(r.Context())
if err != nil {
return data, err
}
data.Groups = groupServices(services, agents, s.hub.Online, s.autoBlocked)
data.ServiceCount = len(services)
data.Sprite = spriteFor(data.Groups)
return data, nil
}
// spriteFor collects the icons the groups' rows use, each once.
func spriteFor(groups []serviceGroup) []spriteIcon {
var sprite []spriteIcon
seen := map[string]bool{}
for _, g := range groups {
for _, row := range g.Services {
if row.Icon == "" || seen[row.Icon] {
continue
}
seen[row.Icon] = true
if path, ok := icons.Path(row.Icon); ok {
sprite = append(sprite, spriteIcon{Slug: row.Icon, Path: path})
}
}
}
return sprite
}
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
data, err := s.serviceData(r)
if err != nil {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "overview.html", "layout", data)
s.page(w, r, http.StatusOK, "overview.html", data)
}
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
@@ -211,7 +328,7 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "overview.html", "services", data)
s.render(w, http.StatusOK, "overview.html", "services-fragment", data)
}
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
@@ -308,7 +425,7 @@ func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
s.failLookup(w, err)
return
}
s.render(w, http.StatusOK, "service.html", "layout", data)
s.page(w, r, http.StatusOK, "service.html", data)
}
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
@@ -470,7 +587,7 @@ func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id i
return
}
data.Error = msg
s.render(w, status, "service.html", "layout", data)
s.page(w, r, status, "service.html", data)
}
// setMode switches a service between manual and auto. Manual is always
@@ -528,7 +645,7 @@ func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "jobs.html", "layout", data)
s.page(w, r, http.StatusOK, "jobs.html", data)
}
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
@@ -559,7 +676,7 @@ func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
if data, ok := s.jobData(w, r); ok {
s.render(w, http.StatusOK, "job.html", "layout", data)
s.page(w, r, http.StatusOK, "job.html", data)
}
}
@@ -571,23 +688,28 @@ func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) {
func (s *Server) agentData(r *http.Request) (pageData, error) {
data := s.base(r, "Agents")
data.MasterVersion = s.cfg.Version
agents, err := s.store.ListAgents(r.Context())
if err != nil {
return data, err
}
for _, a := range agents {
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
data.Agents = append(data.Agents, newAgentRow(a, s.hub.Online(a.ID), s.cfg.Version))
}
return data, nil
}
// agents shows the agent list; with ?add=1 the "add agent" panel is open.
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "agents.html", "layout", data)
if r.URL.Query().Get("add") == "1" {
data.AddStep = 1
}
s.page(w, r, http.StatusOK, "agents.html", data)
}
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
@@ -596,13 +718,41 @@ func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "agents.html", "agents", data)
if data.Nav, err = s.navData(r.Context(), "agents"); err != nil {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "agents.html", "agents-fragment", data)
}
// agentConnectFragment is the last step of the "add agent" panel: it says
// whether the agent that was just added has connected. It never repeats the
// token.
func (s *Server) agentConnectFragment(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
agents, err := s.store.ListAgents(r.Context())
if err != nil {
s.fail(w, err)
return
}
i := slices.IndexFunc(agents, func(a model.Agent) bool { return a.ID == id && a.RevokedAt == nil })
if i < 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
data := s.base(r, "Agents")
data.Created = agents[i]
data.Connected = s.hub.Online(id) || agents[i].LastSeenAt != nil
s.render(w, http.StatusOK, "agents.html", "connect", data)
}
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if !agentNamePattern.MatchString(name) {
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
s.agentsWithError(w, r, http.StatusBadRequest, name, "Agent names use lowercase letters, digits and dashes.")
return
}
token, err := agenttoken.Generate()
@@ -612,28 +762,39 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
}
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
if errors.Is(err, model.ErrConflict) {
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
s.agentsWithError(w, r, http.StatusConflict, name, fmt.Sprintf("An agent named %q already exists.", name))
return
}
if err != nil {
s.fail(w, err)
return
}
data := s.base(r, "Agent created")
data.Created = agent
data.Snippet = agentSnippet(s.cfg, token)
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
s.render(w, http.StatusOK, "agent_created.html", "layout", data)
}
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
data.AddStep = 2
data.Created = agent
data.Token = token
data.MasterKey = s.cfg.MasterKey
data.Snippet = agentSnippet(s.cfg, token)
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
s.page(w, r, http.StatusOK, "agents.html", data)
}
// agentsWithError shows the agent list with the "add agent" panel open at
// its first step, name kept and msg under the field.
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, name, msg string) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
data.AddStep = 1
data.FormName = name
data.Error = msg
s.render(w, status, "agents.html", "layout", data)
s.page(w, r, status, "agents.html", data)
}
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
@@ -648,6 +809,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
return
}
s.hub.Disconnect(id)
s.broker.Publish(events.Agents) // the agent list and the sidebar change
s.broker.Publish(events.Services) // the agent's services were marked removed
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
for _, jobID := range failedJobs {
@@ -656,6 +818,9 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
// pingInterval is how often an idle event stream sends "event: ping".
var pingInterval = 25 * time.Second
// events streams change signals as Server-Sent Events. htmx's SSE extension
// turns "event: services" into a refetch of the services fragment. The event
// carries no state, so a dropped signal is recovered by the next one.
@@ -675,7 +840,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.log.Error("sse_flush_unsupported", "error", err)
return
}
keepalive := time.NewTicker(25 * time.Second)
keepalive := time.NewTicker(pingInterval)
defer keepalive.Stop()
for {
var msg string
@@ -685,7 +850,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
case t := <-ch:
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
case <-keepalive.C:
msg = ": keepalive\n\n"
msg = "event: ping\ndata: ok\n\n" // keeps proxies open; the Live box in the sidebar counts it
}
if _, err := fmt.Fprint(w, msg); err != nil {
return
+128 -25
View File
@@ -221,30 +221,46 @@ func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.B
// the service page shows.
func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
agentID := int64(2)
return newTestEnvConfig(t, func(c *Config) { c.PollInterval = pollInterval })
}
// newTestEnvVersion is newTestEnv with version as the master's build version.
func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
return newTestEnvConfig(t, func(c *Config) { c.Version = version })
}
// newTestEnvConfig is newTestEnv with edit applied to the UI's Config.
func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
agentID, offlineAgentID := int64(2), int64(3)
store := &fakeStore{
created: map[string]string{"node-2": "x"},
services: []model.Service{
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", SwarmClusterID: "c1abcdefgh",
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
{ID: 4, Kind: protocol.KindCompose, ComposeProject: "media", ComposeService: "radarr", AgentID: &offlineAgentID, AgentName: "node-3",
ActualRef: "lscr.io/linuxserver/radarr:latest", Status: "running", UpdateMode: "manual"},
},
agents: []model.Agent{
{ID: 2, Name: "node-2", Hostname: "node-2"},
{ID: 3, Name: "node-3"},
{ID: 2, Name: "node-2", Hostname: "node-2.lan", AgentVersion: "v1.4.0", SwarmManager: true, SwarmClusterID: "c1abcdefgh", LastSeenAt: ptr(time.Now().UTC())},
{ID: 3, Name: "node-3", Hostname: "node-3.lan", AgentVersion: "v1.3.2", LastSeenAt: ptr(time.Date(2026, 10, 12, 13, 51, 0, 0, time.UTC))},
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
{ID: 5, Name: "fresh"},
},
}
hub := &fakeHub{online: map[int64]bool{2: true}}
broker := events.NewBroker()
jobs := &fakeJobs{}
s, err := New(store, hub, jobs, broker, Config{
cfg := Config{
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
PollInterval: pollInterval,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
edit(&cfg)
s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
mux := http.NewServeMux()
s.Register(mux, func(h http.Handler) http.Handler {
@@ -279,13 +295,44 @@ func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
code, body := get(t, srv, "/")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, "<html")
assert.Contains(t, body, "node-2")
assert.Contains(t, body, "Swarm")
assert.Contains(t, body, "slusk/web")
assert.Contains(t, body, "jellyfin_jellyfin")
assert.Contains(t, body, ">Swarm</h2>", "one cluster is just called Swarm")
assert.Contains(t, body, `title="c1abcdefgh"`, "the full cluster id is the hover text")
assert.Contains(t, body, "managers node-2")
assert.Contains(t, body, ">node-2</h2>")
assert.Contains(t, body, `<span class="nmt">web</span>`)
assert.Contains(t, body, `<span class="stk">slusk</span>`)
assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are")
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
assert.Contains(t, body, "managed outside")
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
assert.Contains(t, body, "Managed outside")
assert.Less(t, strings.Index(body, ">Swarm</h2>"), strings.Index(body, ">node-2</h2>"), "a cluster is followed by its managers' machines")
assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster")
}
func TestOverview_OfflineGroupShowsSince(t *testing.T) {
srv, store, hub, _, _ := newTestEnv(t)
_, body := get(t, srv, "/")
assert.Regexp(t, `Agent offline since <time datetime="2026-10-12T13:51:00Z">13:51</time> · showing last known state`, body)
assert.NotContains(t, body, "No manager online", "the cluster has a connected manager")
hub.mu.Lock()
hub.online[2] = false
hub.mu.Unlock()
_, body = get(t, srv, "/")
since := store.agents[0].LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
assert.Contains(t, body, `No manager online since <time datetime="`+since+`">`)
assert.Contains(t, body, ` · showing last known state`)
}
func TestOverview_IconOrLetter(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ActualRef = "gitea/gitea:1.27.3"
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Contains(t, body, `<symbol id="si-gitea"`, path)
assert.Equal(t, 1, strings.Count(body, `<symbol id="si-gitea"`), "the sprite holds each icon once: "+path)
assert.Contains(t, body, `href="#si-gitea"`, path)
assert.Contains(t, body, `class="ltr lc-`, "services without an icon get a letter: "+path)
}
}
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
@@ -293,15 +340,64 @@ func TestServicesFragment_IsNotAFullPage(t *testing.T) {
code, body := get(t, srv, "/fragments/services")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, "slusk/web")
assert.Contains(t, body, `title="slusk/web"`)
assert.Contains(t, body, `id="machines" hx-swap-oob`, "the machine filter in the sidebar is refreshed with the rows")
}
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents")
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*Online`), body)
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*Offline`), body)
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*Revoked`), body)
assert.Regexp(t, regexp.MustCompile(`fresh[\s\S]*Never connected`), body)
assert.Contains(t, body, "Manager")
assert.Contains(t, body, "3 machines", "revoked agents are not counted")
assert.NotContains(t, body, `class="drawer"`, "the add panel is closed by default")
}
func TestAgentsFragment_RefreshesHeaderCount(t *testing.T) {
srv, _, _, _ := newTestServer(t)
code, body := get(t, srv, "/fragments/agents")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `id="agents-sub" hx-swap-oob="true">3 machines`)
}
func TestAgentsPage_RevokeUsesPopover(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents")
assert.Contains(t, body, `<details class="rv" data-id="2">`)
assert.Contains(t, body, `action="/agents/2/revoke"`)
assert.NotContains(t, body, "confirm(")
assert.NotContains(t, body, `action="/agents/4/revoke"`, "a revoked agent cannot be revoked again")
}
func TestAgentsPage_MasterVersionAndOldAgent(t *testing.T) {
cases := []struct {
name, version string
wantOld bool
}{
{"other version is old", "v1.4.0", true},
{"dev master marks nothing", "dev", false},
{"unknown master version marks nothing", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv, _, _, _, _ := newTestEnvVersion(t, c.version)
_, body := get(t, srv, "/agents")
assert.Equal(t, c.version != "", strings.Contains(body, "master "+c.version))
assert.Equal(t, c.wantOld, strings.Contains(body, `<div class="ver old">`+"<span class=\"k\">Agent</span>v1.3.2"), "node-3 runs v1.3.2")
assert.NotContains(t, body, `<div class="ver old"><span class="k">Agent</span>v1.4.0`, "node-2 is current")
})
}
}
func TestAgentsPage_AddPanelOpensWithQuery(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents?add=1")
assert.Contains(t, body, `class="drawer"`)
assert.Contains(t, body, `<form method="post" action="/agents"`)
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
}
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
@@ -319,6 +415,10 @@ func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
assert.Contains(t, string(body), "shown only once")
assert.Contains(t, string(body), `<pre id="snippet">`)
assert.Contains(t, string(body), `data-copy="`+token+`"`)
assert.Contains(t, string(body), `hx-get="/fragments/agents/9/connect"`, "step 3 waits for the agent")
assert.Contains(t, string(body), ">node-3</b>", "the agent list is still on the page")
}
func TestCreateAgent_Validation(t *testing.T) {
@@ -358,16 +458,16 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
assert.Equal(t, int64(2), store.revoked())
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
var got []events.Topic
for len(got) < 4 {
for len(got) < 5 {
select {
case topic := <-ch:
got = append(got, topic)
default:
t.Fatalf("revoke published %v, want services, jobs and one topic per failed job", got)
t.Fatalf("revoke published %v, want agents, services, jobs and one topic per failed job", got)
}
}
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
"the overview drops the agent's services; job lists and open job pages show the failures")
assert.ElementsMatch(t, []events.Topic{events.Agents, events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
"the agent list and sidebar change; the overview drops the agent's services; job lists and open job pages show the failures")
}
func TestRevokeAgent_BadID(t *testing.T) {
@@ -650,16 +750,19 @@ func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
store.services[1].UpdateMode = "auto"
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Contains(t, body, `action="/services/1/mode"`, path)
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
assert.Contains(t, body, `name="return" value="overview"`, path)
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
assert.Contains(t, body, `disabled>Make auto</button> <span class="muted">`+model.ReasonSelf+`</span>`, "blocked manual services get a disabled button: "+path)
assert.Contains(t, body, `class="tag f-up" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Update available<em>1.5.0</em>`, path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`+"\n"+` <button type="submit" class="sw" role="switch" aria-checked="true">`, "auto services toggle to manual: "+path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`, "manual services toggle to auto: "+path)
assert.Contains(t, body, `aria-checked="false" disabled title="`+model.ReasonBuilt+`">`, "blocked manual services get a disabled switch: "+path)
assert.Contains(t, body, `<span class="na">`+model.ReasonBuilt+`</span>`, "and say why: "+path)
assert.Contains(t, body, `<span class="na">n/a</span>`, "services managed outside have no switch: "+path)
}
}
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk) SchibstedGrotesk-Italic[wght].ttf: Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+219 -33
View File
@@ -1,34 +1,220 @@
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
@media (prefers-color-scheme: light) {
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
@font-face{font-family:'Schibsted Grotesk';font-style:normal;font-weight:400 900;font-display:swap;src:url(/static/schibsted-grotesk-latin-wght-normal.woff2) format('woff2')}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url(/static/jetbrains-mono-latin-wght-normal.woff2) format('woff2')}
/* Ledger. Tokens live on <html class="app">; a script in <head> adds t-light before first paint. */
.app{color-scheme:dark;--bg:#0f1218;--side:#0b0d12;--card:#151a23;--hover:#171c26;--line:#232a37;--line2:#323a4a;--fg:#e7eaf1;--mut:#a0a9bb;--dim:#828ba0;--blue:#74a8ff;--bluebg:rgba(116,168,255,.12);--orange:#ffa262;--orangebg:rgba(255,162,98,.12);--green:#62d4a0;--red:#ff7d88;--redbg:rgba(255,125,136,.10);--violet:#c5a9ff;--violetbg:rgba(197,169,255,.12);--l0:#74a8ff;--l1:#c5a9ff;--l2:#5fd0e0;--l3:#f29bd0;--onblue:#06142e;--term:#080a0e;--shadow:0 12px 32px rgba(0,0,0,.5);--sans:'Schibsted Grotesk',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.45 var(--sans)}
.app.t-light{color-scheme:light;--bg:#f7f8fa;--side:#eceff4;--card:#ffffff;--hover:#f0f2f6;--line:#dde1e8;--line2:#c6ccd7;--fg:#121620;--mut:#4b5468;--dim:#5c6579;--blue:#2156c8;--bluebg:rgba(33,86,200,.08);--orange:#a84b0b;--orangebg:rgba(200,96,20,.09);--green:#177649;--red:#b32a3b;--redbg:rgba(179,42,59,.07);--violet:#6943c1;--violetbg:rgba(105,67,193,.08);--l0:#2156c8;--l1:#6943c1;--l2:#0b7285;--l3:#a8306f;--onblue:#ffffff;--term:#121620;--shadow:0 12px 32px rgba(20,30,50,.16)}
*{box-sizing:border-box}
body{margin:0}
[hidden]{display:none !important}
a{color:var(--blue)}a:hover{color:var(--fg)}
button,input{font-family:inherit}
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
/* Shell */
.shell{display:flex;flex-wrap:wrap;min-height:100vh}
.side{flex:0 0 232px;background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:28px;padding:20px 14px}
.brand{display:flex;align-items:center;gap:10px;font:700 15px var(--sans);letter-spacing:-.01em;padding:0 8px;color:var(--fg);text-decoration:none}
.brand svg{color:var(--blue)}
.snav{display:flex;flex-direction:column;gap:2px}
.snav a,.mlist a{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;color:var(--mut);text-decoration:none;font-weight:500}
.snav a:hover,.mlist a:hover{background:var(--hover);color:var(--fg)}
.snav a.cur,.mlist a.cur{background:var(--card);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line)}
.snav .n,.mlist .n{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.slabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 8px 6px}
.mlist{display:flex;flex-direction:column;gap:2px}
.mlist .sub{padding-left:22px;font-size:13px}
.mdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
.mdot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--red)}
.sw-icon{width:7px;height:7px;flex:none;border-radius:2px;box-shadow:inset 0 0 0 1.5px var(--blue)}
.livebox{margin-top:auto;padding:10px 8px;border-top:1px solid var(--line);font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px}
.livebox b{display:flex;align-items:center;gap:8px;color:var(--fg);font-weight:600}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);animation:breathe 2.4s ease-in-out infinite}
.livebox.down .pulse{background:var(--red);animation:none}
@keyframes breathe{0%{box-shadow:0 0 0 0 rgba(98,212,160,.45)}50%{box-shadow:0 0 0 5px rgba(98,212,160,0)}}
.sfoot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}
.sfoot form{margin:0}
.work{flex:999 1 640px;min-width:0;display:flex;flex-wrap:wrap}
.main{flex:999 1 520px;min-width:0;padding:28px 36px 48px;container-type:inline-size}
.sprite{position:absolute;width:0;height:0;overflow:hidden}
/* Page header, buttons */
.hdr{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 24px;margin-bottom:18px}
.hdr h1{margin:0;font:700 34px/1 var(--sans);letter-spacing:-.025em}
.hdr .sub{font-size:14px;color:var(--mut)}
.hdr .end{margin-left:auto}
.btn,button:not([class]){height:32px;padding:0 14px;border-radius:6px;border:1px solid var(--line2);background:var(--card);color:var(--fg);font:600 13px var(--sans);cursor:pointer;display:inline-flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}
.btn:hover,button:not([class]):hover{border-color:var(--dim);color:var(--fg)}
button:not([class]):disabled{opacity:.5;cursor:not-allowed}
.btn.pri,.btn.pri:hover{background:var(--blue);border-color:var(--blue);color:var(--onblue)}
.btn.sm{height:28px;padding:0 10px;font-size:12.5px}
.btn.quiet{border-color:transparent;background:transparent;color:var(--mut)}
.btn.quiet:hover{color:var(--fg)}
.btn.red{color:var(--red);border-color:var(--line2);background:transparent}
.btn.redfill,.btn.redfill:hover{background:var(--red);border-color:var(--red);color:#1d0408}
summary.btn{list-style:none}
summary.btn::-webkit-details-marker{display:none}
/* Overview */
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:12px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:8px}
.search{flex:0 1 340px;min-width:220px;display:flex;align-items:center;gap:8px;height:34px;padding:0 12px;border-radius:6px;background:var(--card);border:1px solid var(--line2);color:var(--dim)}
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font-size:14px}
.search input::placeholder{color:var(--dim)}
.tools .end{margin-left:auto;display:flex;align-items:center;gap:10px}
.tools .lbl{font-size:12.5px;color:var(--dim)}
.seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2)}
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
.seg button:hover{color:var(--fg)}
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
.grp{margin-top:28px}
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.gh .kind{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--mut)}
.gh .meta{font-size:13px;color:var(--dim)}
.gh .warn{font-size:13px;color:var(--red);font-weight:500}
.gh .count{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.grp.offline .gh{border-bottom-color:var(--line2)}
.grp.offline .row{color:var(--dim)}
.row{position:relative;display:grid;grid-template-columns:minmax(200px,1.4fr) minmax(200px,2fr) 130px minmax(160px,1.5fr) minmax(104px,150px);align-items:center;column-gap:18px;min-height:42px;padding:0 6px;border-bottom:1px solid var(--line)}
.row:hover{background:var(--hover)}
.nm{display:flex;align-items:center;gap:8px;min-width:0}
.nm a{display:inline-flex;align-items:center;gap:10px;min-width:0;color:var(--fg);font-weight:600;text-decoration:none;white-space:nowrap}
.nm a:hover .nmt{text-decoration:underline;text-underline-offset:3px}
.nm .nmt{overflow:hidden;text-overflow:ellipsis}
.nm .stk{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ico{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2);color:var(--fg)}
.ico svg{fill:currentColor}
.ltr{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font:700 12.5px var(--sans)}
.lc-0{color:var(--l0);background:color-mix(in srgb,var(--l0) 18%,transparent)}
.lc-1{color:var(--l1);background:color-mix(in srgb,var(--l1) 18%,transparent)}
.lc-2{color:var(--l2);background:color-mix(in srgb,var(--l2) 18%,transparent)}
.lc-3{color:var(--l3);background:color-mix(in srgb,var(--l3) 18%,transparent)}
.grp.offline .ico,.grp.offline .ltr{opacity:.65}
.d-icon .nmt,.d-icon .stk{display:none}
.d-text .ico,.d-text .ltr{display:none}
.ver{display:flex;min-width:0;font:12.5px var(--mono);color:var(--dim);white-space:nowrap}
.ver .r{overflow:hidden;text-overflow:ellipsis;min-width:0}
.ver .t{flex:none}
.ver b{color:var(--fg);font-weight:500}
.st{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500;white-space:nowrap}
.st svg{flex:none}
.st circle{fill:currentColor}
.s-running{color:var(--green)}.s-degraded{color:var(--orange)}.s-stopped{color:var(--dim)}.s-unknown{color:var(--dim)}
.s-stopped circle,.s-unknown circle{fill:none}
.flags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font:500 12px/20px var(--sans);padding:0 8px;border-radius:999px;white-space:nowrap}
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
.tag em:empty{display:none}
.f-up{color:var(--orange);background:var(--orangebg)}
.f-err{color:var(--red);background:var(--redbg)}
.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.sw{display:inline-flex;align-items:center;gap:8px;padding:0;border:0;background:none;font:inherit;font-size:13px;color:var(--mut);cursor:pointer}
.sw i{flex:none;width:30px;height:18px;border-radius:999px;background:var(--line2);position:relative;transition:background .15s}
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .15s}
.sw[aria-checked=true] i{background:var(--blue)}
.sw[aria-checked=true] i::after{transform:translateX(12px);background:var(--onblue)}
.sw[aria-checked=true]{color:var(--blue);font-weight:600}
.sw:disabled{opacity:.55;cursor:not-allowed}
.na{font-size:12.5px;color:var(--dim)}
.nomatch{padding:24px 6px;color:var(--dim)}
/* Agents */
.hdr.ruled{padding-bottom:16px;border-bottom:2px solid var(--fg);margin-bottom:0}
.agent{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) 130px 90px 110px 120px 84px;grid-template-areas:"id st ver mgr last act";align-items:center;column-gap:18px;padding:16px 6px;border-bottom:1px solid var(--line)}
.agent .id{grid-area:id}
.agent .id b{display:block;font:700 16px var(--sans)}
.agent .id span{font:12.5px var(--mono);color:var(--dim)}
.agent .stc{grid-area:st}.agent .ver{grid-area:ver;display:block;font:13px var(--mono);color:var(--mut)}.agent .mgr{grid-area:mgr;font-size:13px;color:var(--mut)}.agent .last{grid-area:last;font-size:13px;color:var(--mut)}.agent .act{grid-area:act;display:flex;justify-content:flex-end}
.agent .k{display:block;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:2px}
.agent .ver.old{color:var(--orange)}
.agent .st{font-size:13.5px;font-weight:600}
.agent .st i{width:8px;height:8px;border-radius:50%;background:currentColor}
.a-on{color:var(--green)}.a-off{color:var(--red)}.a-never,.a-revoked{color:var(--dim)}
.a-off i,.a-never i{background:transparent !important;box-shadow:inset 0 0 0 1.5px currentColor}
/* After the .agent rules: a container query adds no specificity, so it must come later to win. */
@container (max-width:760px){
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
}
.pop{position:absolute;right:6px;top:calc(100% - 8px);z-index:5;width:360px;padding:14px 16px;border-radius:8px;background:var(--card);border:1px solid var(--line2);box-shadow:var(--shadow);font-size:13px;color:var(--mut)}
.pop::before{content:"";position:absolute;top:-6px;right:30px;width:10px;height:10px;background:var(--card);border-left:1px solid var(--line2);border-top:1px solid var(--line2);transform:rotate(45deg)}
.pop h4{margin:0 0 4px;font:700 14px var(--sans);color:var(--fg)}
.pop p{margin:0 0 12px}
.pop .acts{display:flex;gap:8px;justify-content:flex-end;margin:0}
.drawer{flex:1 1 440px;min-width:0;max-width:520px;border-left:1px solid var(--line2);background:var(--card);padding:28px 28px 40px;display:flex;flex-direction:column;gap:18px}
.dh{display:flex;align-items:center;gap:12px}
.dh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.dh .x{margin-left:auto}
.steps{display:flex;gap:8px;font-size:12.5px;color:var(--dim)}
.steps span{display:flex;align-items:center;gap:6px}
.steps b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;font:600 11px var(--mono);box-shadow:inset 0 0 0 1px var(--line2)}
.steps .done{color:var(--green)}.steps .done b{box-shadow:none;background:var(--green);color:var(--bg)}
.steps .now{color:var(--fg)}.steps .now b{box-shadow:none;background:var(--blue);color:var(--onblue)}
.steps .sep{width:16px;height:1px;background:var(--line2)}
.dform{display:flex;flex-direction:column;gap:18px;margin:0}
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;color:var(--mut)}
.field input{height:36px;padding:0 12px;border-radius:6px;border:1px solid var(--line2);background:var(--bg);color:var(--fg);font:500 14px var(--mono)}
.field small{color:var(--dim);font-size:12.5px}
.namebox{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:6px;background:var(--bg);font-size:13.5px}
.namebox code{font:500 14px var(--mono);color:var(--fg)}
.notice{display:flex;gap:10px;padding:12px 14px;border-radius:6px;background:var(--orangebg);color:var(--fg);font-size:13.5px}
.notice svg{flex:none;color:var(--orange);margin-top:1px}
.code{border-radius:6px;background:var(--term);overflow:hidden}
.code-h{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 12px;border-bottom:1px solid #1d2230;font:12px var(--mono);color:#8d96a8}
.code pre{margin:0;padding:12px;border:0;color:#cdd3df;font:12px/1.6 var(--mono);overflow-x:auto}
.secret{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px 12px;font-size:13px;color:var(--mut)}
.secret code{font:12.5px var(--mono);color:var(--fg);overflow-wrap:anywhere}
.df{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:auto}
.df form{margin:0}
.df > span{margin-right:auto;font-size:13px;color:var(--mut);display:flex;align-items:center;gap:8px}
.df .ok{color:var(--green)}
.spin{width:12px;height:12px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line2);border-top:2px solid var(--blue);box-sizing:border-box;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Pages that keep the plain markup: service, jobs, job */
h1{font:700 34px/1.1 var(--sans);letter-spacing:-.025em;margin:0 0 18px}
h2{font:700 20px var(--sans);letter-spacing:-.015em;margin:28px 0 8px}
table{width:100%;border-collapse:collapse;margin-bottom:24px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
th{color:var(--mut);font-weight:500}
code,pre{font:13px var(--mono)}
pre{padding:12px;border:1px solid var(--line);border-radius:6px;overflow-x:auto}
.badge{font-size:12px;color:var(--mut);border:1px solid var(--line);padding:0 6px;border-radius:8px}
.status-running{color:var(--green)}
.status-degraded,.status-unknown{color:var(--orange)}
.status-stopped{color:var(--red)}
.error{color:var(--red)}
.warning{color:var(--orange)}
.muted{color:var(--dim)}
.status-job-succeeded{color:var(--green)}
.status-job-queued,.status-job-running{color:var(--orange)}
.status-job-failed{color:var(--red)}
table.details{width:auto}
table.details th{padding-right:24px}
pre.log{white-space:pre-wrap}
@media (max-width:760px){
.side{flex:1 1 100%;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}
#snav{order:3;width:100%}
.snav{flex-direction:row;overflow-x:auto}
.snav a{padding:10px 12px}
.snav .n{margin-left:4px}
.mblock{display:none}
.livebox{margin:0 0 0 auto;border:0;padding:0}
.livebox #live-age{display:none}
.main{padding:20px 16px 40px}
.hdr h1{font-size:28px}
.hdr .end,.tools .end{margin-left:0}
.search{flex:1 1 100%}
.btn,button:not([class]){height:44px}
.btn.sm{height:36px}
.row{grid-template-columns:1fr auto;grid-template-areas:"nm st" "ver ver" "fl fl" "mode mode";row-gap:8px;padding:12px 4px}
.nm{grid-area:nm}.stc{grid-area:st}.row .ver{grid-area:ver}.flags{grid-area:fl}.mode{grid-area:mode}
.flags:empty{display:none}
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
.drawer{order:-1;max-width:none;border-left:0;border-bottom:1px solid var(--line2);padding:20px 16px}
.pop{left:0;right:0;width:auto}
table{display:block;overflow-x:auto}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
nav form { margin-left: auto; }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
a { color: var(--accent); }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
code, pre { font: 13px ui-monospace, monospace; }
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
.status-running { color: var(--ok); }
.status-degraded, .status-unknown { color: var(--warn); }
.status-stopped { color: var(--bad); }
.error { color: var(--bad); }
.warning { color: var(--warn); }
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
.muted { color: var(--muted); }
.status-job-succeeded { color: var(--ok); }
.status-job-queued, .status-job-running { color: var(--warn); }
.status-job-failed { color: var(--bad); }
table.details { width: auto; }
table.details th { padding-right: 24px; }
pre.log { white-space: pre-wrap; }
.badge-update { color: var(--warn); border-color: var(--warn); }
.badge-error { color: var(--bad); border-color: var(--bad); }
form.mode { display: inline; margin-left: 8px; }
form.mode button { font-size: 12px; padding: 0 6px; }
+226
View File
@@ -0,0 +1,226 @@
// Behaviour the server-rendered pages need in the browser: theme, display
// mode, search and machine filter, the Live box, local times, copy buttons and
// the revoke popovers. The pages work without it; htmx and the SSE extension
// do the live updates. Everything stored in the browser is wrapped in
// try/catch because storage can be blocked.
(function () {
'use strict';
var root = document.documentElement;
function load(area, key) {
try { return window[area].getItem(key); } catch (e) { return null; }
}
function save(area, key, value) {
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
}
function each(selector, fn) {
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
}
// Theme: the head script set the initial class; the toggle overrides it.
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
var themeButton = document.getElementById('theme-toggle');
if (themeButton) {
themeButton.addEventListener('click', function () {
var light = !root.classList.contains('t-light');
root.classList.toggle('t-light', light);
themeButton.setAttribute('aria-pressed', String(light));
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
});
themeButton.setAttribute('aria-pressed', String(root.classList.contains('t-light')));
}
if (colorScheme && colorScheme.addEventListener) {
colorScheme.addEventListener('change', function (e) {
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
});
}
// Display mode: icon, icon + text, or text.
function setDisplay(mode) {
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
}
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
// Search and machine filter. The rows are all in the page; this hides them.
var filter = { q: '', machine: '' };
try {
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
if (typeof saved.q === 'string') filter.q = saved.q;
if (typeof saved.machine === 'string') filter.machine = saved.machine;
} catch (e) { /* ignore a broken saved filter */ }
var search = document.getElementById('q');
if (search) search.value = filter.q;
// The machine list is hidden on narrow screens; a filter that cannot be
// seen or cleared there is ignored. Search still applies.
var narrow = window.matchMedia ? window.matchMedia('(max-width:760px)') : null;
function applyFilter() {
var box = document.getElementById('services');
if (!box) return;
var groups = box.querySelectorAll('.grp');
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
if (filter.machine && !known) filter.machine = '';
var machine = narrow && narrow.matches ? '' : filter.machine;
var q = filter.q.trim().toLowerCase();
var any = false;
Array.prototype.forEach.call(groups, function (g) {
var inMachine = !machine || g.dataset.key === machine;
var shown = 0;
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
row.hidden = !match;
if (match) shown++;
});
g.hidden = shown === 0;
if (shown > 0) any = true;
});
var none = document.getElementById('nomatch');
if (none) none.hidden = any || groups.length === 0;
each('#machines a', function (a) {
var cur = a.dataset.machine === filter.machine;
a.classList.toggle('cur', cur);
if (cur) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
});
}
if (narrow && narrow.addEventListener) narrow.addEventListener('change', function () { applyFilter(); });
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
if (search) {
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
}
// Live box: counts any event on the stream, including the server's ping.
var live = document.getElementById('live');
var liveAge = document.getElementById('live-age');
var lastEvent = null;
var down = false;
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
function touch() { lastEvent = Date.now(); }
document.addEventListener('htmx:sseOpen', function (e) {
var source = e.detail && e.detail.source;
touch();
down = false;
if (source && wired && !wired.has(source)) {
wired.add(source);
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
}
});
document.addEventListener('htmx:sseError', function () { down = true; });
function renderLive() {
if (!live || !liveAge) return;
live.classList.toggle('down', down);
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
}
// Times: the server writes <time datetime="..."> with its own clock as
// fallback text; show them in the browser's time zone.
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n) { return (n < 10 ? '0' : '') + n; }
function localiseTimes() {
each('time[datetime]', function (t) {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var text;
if (t.hasAttribute('data-rel')) {
var min = Math.floor((Date.now() - d.getTime()) / 60000);
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
t.title = d.toLocaleString();
} else {
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
}
if (t.textContent !== text) t.textContent = text;
});
}
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
function flash(button, text) {
if (!button.dataset.label) button.dataset.label = button.textContent;
button.textContent = text;
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
}
function copyFallback(button, text) {
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
if (target) {
var range = document.createRange();
range.selectNodeContents(target);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
flash(button, 'Press Ctrl+C');
return;
}
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
document.body.removeChild(area);
flash(button, ok ? 'Copied' : 'Copy failed');
}
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
var openRevokes = [];
document.addEventListener('click', function (e) {
var display = e.target.closest('[data-display]');
if (display) {
setDisplay(display.dataset.display);
save('localStorage', 'pu-display', display.dataset.display);
return;
}
var machine = e.target.closest('#machines a');
if (machine) {
e.preventDefault();
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
saveFilter();
applyFilter();
return;
}
var close = e.target.closest('[data-close]');
if (close) {
var details = close.closest('details');
if (details) details.open = false;
return;
}
var copy = e.target.closest('[data-copy], [data-copy-target]');
if (copy) {
var text = copy.dataset.copy;
if (text === undefined) {
var el = document.getElementById(copy.dataset.copyTarget);
text = el ? el.textContent : '';
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
} else {
copyFallback(copy, text);
}
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
});
document.addEventListener('htmx:beforeSwap', function () {
openRevokes = [];
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
});
document.addEventListener('htmx:afterSwap', function () {
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
});
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
applyFilter();
localiseTimes();
renderLive();
setInterval(renderLive, 1000);
setInterval(localiseTimes, 15000);
})();
@@ -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}}
+101 -31
View File
@@ -1,38 +1,108 @@
{{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" id="agents-sub">{{template "agents-sub" .}}</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.')">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit">Revoke</button>
</form>
{{end}}</td>
</tr>
{{else}}
<tr><td colspan="7">No agents yet.</td></tr>
{{end}}
</tbody>
</table>
{{define "agents-sub"}}{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}{{end}}
{{/* What /fragments/agents returns: the list, and the header count swapped in out of band. */}}
{{define "agents-fragment"}}
{{template "agents" .}}
<span class="sub" id="agents-sub" hx-swap-oob="true">{{template "agents-sub" .}}</span>
{{end}}
{{define "agents"}}
{{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="button" class="btn sm quiet" data-close>Cancel</button>
<button type="submit" class="btn sm redfill">Revoke</button>
</form>
</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}}
+47 -8
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>
<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">Log out</button>
<button type="submit" class="btn sm quiet">Log out</button>
</form>
</nav>
<main>{{template "content" .}}</main>
</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}}
+87 -22
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="/" aria-current="true" 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>
<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}}
<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">
<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">Make manual</button>
<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"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>{{if .AutoBlocked}} <span class="muted">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>
</td>
</tr>
<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}}
</tbody>
</table>
</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}}
+3 -3
View File
@@ -214,8 +214,8 @@ func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Equal(t, 1, strings.Count(body, "follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="badge badge-error" title="head x: &#34;boom&#34; &lt;script&gt;">poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "poll failed"), path)
assert.Equal(t, 1, strings.Count(body, "Follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="tag f-err" title="head x: &#34;boom&#34; &lt;script&gt;">Poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
}
}
+242 -22
View File
@@ -1,58 +1,239 @@
package web
import (
"cmp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/shcizo/package-updater/internal/imageref"
"github.com/shcizo/package-updater/internal/master/jobs"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
"github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/protocol"
)
// serviceGroup is one block of the overview: a machine, a Swarm cluster or
// the services whose agent is unknown.
type serviceGroup struct {
Key string // "agent-<id>", "swarm-<cluster id>" or "unknown"; the machine filter matches on it
Title string
TitleHint string // hover text of the title; the full cluster id
Kind string // "Machine" or "Swarm cluster"
Meta string // hostname, or the cluster's managers
Cluster bool
Sub bool // a machine that belongs to the cluster shown above it
// Offline says the agent (or, for a cluster, every manager) is not
// connected, so the rows show the last known state.
Offline bool
OfflineLabel string // what the banner says before "since"
OfflineSince *time.Time // when the agent was last seen; nil if unknown
Services []serviceRow
}
// serviceRow is a service as shown in the overview table.
// serviceRow is a service as shown in the overview.
type serviceRow struct {
model.Service
AutoBlocked string // why the service cannot be set to auto; "" when it can
Name string // the service's own name, without its stack
Stack string // the Compose project; "" for Swarm and plain containers
Repo string // image repository without the registry host
Tag string // ":tag", or ""
Digest string // "@sha256:<12 hex>…" when the image has no tag, else ""
AvailableTag string // the tag of AvailableRef, or ""
Icon string // Simple Icons slug; "" when there is no icon
Letter string // shown instead of an icon
LetterClass string // lc-0 .. lc-3
StatusLabel string
StatusClass string
Search string // what the search box matches against, lowercased
}
// groupServices groups machine-bound services per agent and Swarm services
// in one group, keeping the store's order (machines first, Swarm last).
// autoBlocked is called for every service and returns the reason it cannot be
// set to auto, or "" when it can.
func groupServices(services []model.Service, autoBlocked func(model.Service) string) []serviceGroup {
var groups []serviceGroup
index := map[string]int{}
// newServiceRow fills the display fields of a row.
func newServiceRow(s model.Service, autoBlocked string) serviceRow {
r := serviceRow{Service: s, AutoBlocked: autoBlocked}
switch s.Kind {
case protocol.KindCompose:
r.Name, r.Stack = s.ComposeService, s.ComposeProject
case protocol.KindSwarm:
r.Name = s.SwarmService
default:
r.Name = s.ContainerName
}
r.Repo, r.Tag, r.Digest = imageParts(s.ActualRef)
r.AvailableTag = imageref.Parse(s.AvailableRef).Tag
r.Icon = icons.ForImage(s.ActualRef)
r.Letter = "?"
if first, _ := utf8.DecodeRuneInString(r.Name); first != utf8.RuneError {
r.Letter = strings.ToUpper(string(first))
}
r.LetterClass = letterClass(r.Name)
switch s.Status {
case "running":
r.StatusLabel, r.StatusClass = "Running", "s-running"
case "degraded":
r.StatusLabel, r.StatusClass = "Degraded", "s-degraded"
case "stopped":
r.StatusLabel, r.StatusClass = "Stopped", "s-stopped"
default:
r.StatusLabel, r.StatusClass = "Unknown", "s-unknown"
}
r.Search = strings.ToLower(strings.Join([]string{r.Name, r.Stack, r.Repo, strings.TrimPrefix(r.Tag, ":"), s.AgentName}, " "))
return r
}
// imageParts splits an image reference for display: the repository without
// its registry host, ":tag", and a shortened digest that is only given when
// there is no tag.
func imageParts(ref string) (repo, tag, digest string) {
r := imageref.Parse(ref)
repo = r.Repo
if host, rest, ok := strings.Cut(repo, "/"); ok && (imageref.IsRegistryHost(host)) {
repo = rest
}
if r.Tag != "" {
return repo, ":" + r.Tag, ""
}
if r.Digest != "" {
return repo, "", "@" + shortDigest(r.Digest)
}
return repo, "", ""
}
// shortDigest shortens "sha256:<hex>" to 12 hex characters and an ellipsis.
func shortDigest(d string) string {
if hex, ok := strings.CutPrefix(d, "sha256:"); ok && len(hex) > 12 {
return "sha256:" + hex[:12] + "…"
}
return d
}
// letterClass picks one of four colours from the name, so a service keeps
// its colour.
func letterClass(name string) string {
sum := 0
for i := 0; i < len(name); i++ {
sum += int(name[i])
}
return "lc-" + strconv.Itoa(sum%4)
}
// groupServices groups the services into one block per Swarm cluster and per
// machine. A cluster comes first, followed by the machines that belong to it;
// the remaining machines follow by name and the services whose agent is
// unknown come last. Agents without services get no block. online says
// whether an agent is connected; autoBlocked returns the reason a service
// cannot be set to auto, or "".
func groupServices(services []model.Service, agents []model.Agent, online func(int64) bool, autoBlocked func(model.Service) string) []serviceGroup {
agentByID := map[int64]model.Agent{}
for _, a := range agents {
agentByID[a.ID] = a
}
var clusterIDs []string
clusterRows := map[string][]serviceRow{}
clusterRowAgents := map[string]map[int64]bool{} // agents named by a cluster's rows
machineRows := map[int64][]serviceRow{}
var unknownRows []serviceRow
for _, s := range services {
title := s.AgentName
if s.Kind == protocol.KindSwarm {
title = "Swarm"
} else if title == "" {
title = "Unknown agent"
row := newServiceRow(s, autoBlocked(s))
_, known := agentByID[derefID(s.AgentID)]
switch {
case s.Kind == protocol.KindSwarm:
if _, seen := clusterRows[s.SwarmClusterID]; !seen {
clusterIDs = append(clusterIDs, s.SwarmClusterID)
clusterRowAgents[s.SwarmClusterID] = map[int64]bool{}
}
i, ok := index[title]
if !ok {
i = len(groups)
index[title] = i
groups = append(groups, serviceGroup{Title: title})
clusterRows[s.SwarmClusterID] = append(clusterRows[s.SwarmClusterID], row)
if s.AgentID != nil {
clusterRowAgents[s.SwarmClusterID][*s.AgentID] = true
}
groups[i].Services = append(groups[i].Services, serviceRow{Service: s, AutoBlocked: autoBlocked(s)})
case s.AgentID != nil && known:
machineRows[*s.AgentID] = append(machineRows[*s.AgentID], row)
default:
unknownRows = append(unknownRows, row)
}
}
machineIDs := make([]int64, 0, len(machineRows))
for id := range machineRows {
machineIDs = append(machineIDs, id)
}
slices.SortFunc(machineIDs, func(a, b int64) int {
return cmp.Or(cmp.Compare(agentByID[a].Name, agentByID[b].Name), cmp.Compare(a, b))
})
machine := func(id int64, sub bool) serviceGroup {
a := agentByID[id]
return serviceGroup{
Key: "agent-" + strconv.FormatInt(id, 10), Title: a.Name, Kind: "Machine", Meta: a.Hostname, Sub: sub,
Offline: !online(id), OfflineLabel: "Agent offline", OfflineSince: a.LastSeenAt, Services: machineRows[id],
}
}
var groups []serviceGroup
placed := map[int64]bool{}
for _, cid := range clusterIDs {
title := "Swarm"
if len(clusterIDs) > 1 {
title += " " + cid[:min(len(cid), 8)]
}
g := serviceGroup{
Key: "swarm-" + cid, Title: title, TitleHint: cid, Kind: "Swarm cluster", Cluster: true,
Offline: true, OfflineLabel: "No manager online", Services: clusterRows[cid],
}
var names []string
for _, a := range agents {
isManager := a.SwarmManager && a.SwarmClusterID == cid
if a.RevokedAt != nil || !(isManager || clusterRowAgents[cid][a.ID]) {
continue
}
names = append(names, a.Name)
if online(a.ID) {
g.Offline = false
}
if a.LastSeenAt != nil && (g.OfflineSince == nil || a.LastSeenAt.After(*g.OfflineSince)) {
g.OfflineSince = a.LastSeenAt
}
}
slices.Sort(names)
if len(names) > 0 {
g.Meta = "managers " + strings.Join(names, ", ")
}
groups = append(groups, g)
for _, id := range machineIDs {
if cid != "" && agentByID[id].SwarmClusterID == cid && !placed[id] {
placed[id] = true
groups = append(groups, machine(id, true))
}
}
}
for _, id := range machineIDs {
if !placed[id] {
groups = append(groups, machine(id, false))
}
}
if len(unknownRows) > 0 {
groups = append(groups, serviceGroup{
Key: "unknown", Title: "Unknown agent", Kind: "Machine", Offline: true, OfflineLabel: "Agent offline", Services: unknownRows,
})
}
return groups
}
func derefID(id *int64) int64 {
if id == nil {
return 0
}
return *id
}
// shortRef shortens a digest in an image reference to 12 hex characters.
func shortRef(ref string) string {
r := imageref.Parse(ref)
if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 {
r.Digest = "sha256:" + hex[:12] + "…"
}
r.Digest = shortDigest(r.Digest)
return r.String()
}
@@ -101,9 +282,48 @@ func shortDuration(d time.Duration) string {
return s
}
// agentRow is an agent as shown on the agents page.
type agentRow struct {
model.Agent
Online bool
Status string // Online, Offline, Never connected or Revoked
StatusClass string
Role string // Manager, Worker or "—"
VersionOld bool // the agent runs another version than the master
}
// newAgentRow fills the display fields of a row. masterVersion is the
// master's build version; "dev" and "" never mark an agent as old.
func newAgentRow(a model.Agent, online bool, masterVersion string) agentRow {
r := agentRow{Agent: a, Online: online}
switch {
case a.RevokedAt != nil:
r.Status, r.StatusClass = "Revoked", "a-revoked"
case online:
r.Status, r.StatusClass = "Online", "a-on"
case a.LastSeenAt == nil:
r.Status, r.StatusClass = "Never connected", "a-never"
default:
r.Status, r.StatusClass = "Offline", "a-off"
}
switch {
case a.SwarmManager:
r.Role = "Manager"
case a.SwarmClusterID != "":
r.Role = "Worker"
default:
r.Role = "—"
}
r.VersionOld = a.AgentVersion != "" && masterVersion != "" && masterVersion != "dev" && a.AgentVersion != masterVersion
return r
}
// middle shortens a long secret for display: its first 8 and last 4 characters.
func middle(s string) string {
if len(s) <= 14 {
return s
}
return s[:8] + "…" + s[len(s)-4:]
}
// jobDuration is how long a job ran, or has been running; "" before it starts.
+213
View File
@@ -0,0 +1,213 @@
package web
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/protocol"
)
func TestImageParts(t *testing.T) {
cases := []struct {
ref, repo, tag, digest string
}{
{"postgres:17.10", "postgres", ":17.10", ""},
{"postgres", "postgres", "", ""},
{"gitea/gitea:1.27.3", "gitea/gitea", ":1.27.3", ""},
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", "shcizo/slusk", ":v0.8.1", ""},
{"localhost:5000/foo:v1", "foo", ":v1", ""},
{"localhost/foo", "foo", "", ""},
{"lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", "linuxserver/jellyfin", "", "@sha256:0f42497a69fa…"},
{"r/x:1.0@sha256:0f42497a69fa0441", "r/x", ":1.0", ""}, // the digest is hidden when there is a tag
{"r/x@sha256:abc", "r/x", "", "@sha256:abc"},
}
for _, c := range cases {
t.Run(c.ref, func(t *testing.T) {
repo, tag, digest := imageParts(c.ref)
assert.Equal(t, c.repo, repo)
assert.Equal(t, c.tag, tag)
assert.Equal(t, c.digest, digest)
})
}
}
func TestLetterClass(t *testing.T) {
assert.Equal(t, "lc-0", letterClass(""))
assert.Equal(t, "lc-1", letterClass("a"), "97 mod 4")
assert.Equal(t, letterClass("gitea"), letterClass("gitea"), "a name always gets the same colour")
for _, name := range []string{"gitea", "authentik-server", "ø", "n8n"} {
assert.Regexp(t, `^lc-[0-3]$`, letterClass(name))
}
}
func TestNewAgentRow(t *testing.T) {
seen := ptr(time.Now())
cases := []struct {
name string
agent model.Agent
online bool
masterVersion string
status, role string
old bool
}{
{"online manager", model.Agent{SwarmManager: true, AgentVersion: "v1"}, true, "v1", "Online", "Manager", false},
{"offline worker on another version", model.Agent{SwarmClusterID: "c", AgentVersion: "v0", LastSeenAt: seen}, false, "v1", "Offline", "Worker", true},
{"never connected", model.Agent{}, false, "v1", "Never connected", "—", false},
{"revoked wins over online", model.Agent{RevokedAt: seen, LastSeenAt: seen}, true, "v1", "Revoked", "—", false},
{"dev master marks nothing", model.Agent{AgentVersion: "v0"}, true, "dev", "Online", "—", false},
{"agent without version", model.Agent{}, true, "v1", "Online", "—", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
row := newAgentRow(c.agent, c.online, c.masterVersion)
assert.Equal(t, c.status, row.Status)
assert.Equal(t, c.role, row.Role)
assert.Equal(t, c.old, row.VersionOld)
})
}
}
func TestMiddle(t *testing.T) {
assert.Equal(t, "short", middle("short"))
assert.Equal(t, "pua_7Hq2…Ge5A", middle("pua_7Hq2mX9vKc4LwT8nRb3ZyF6dJp1sGe5A"))
}
func TestGroupServices(t *testing.T) {
at := func(h, m int) *time.Time { return ptr(time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)) }
id := func(n int64) *int64 { return &n }
compose := func(sid int64, agent *int64, name string) model.Service {
return model.Service{ID: sid, Kind: protocol.KindCompose, AgentID: agent, ComposeProject: "p", ComposeService: name, ActualRef: "r/" + name + ":1"}
}
swarm := func(sid int64, cluster string, agent *int64) model.Service {
return model.Service{ID: sid, Kind: protocol.KindSwarm, SwarmClusterID: cluster, AgentID: agent, SwarmService: "s" + cluster}
}
noBlock := func(model.Service) string { return "" }
onlineSet := func(ids ...int64) func(int64) bool {
return func(a int64) bool {
for _, i := range ids {
if i == a {
return true
}
}
return false
}
}
titles := func(groups []serviceGroup) []string {
var out []string
for _, g := range groups {
out = append(out, g.Title)
}
return out
}
t.Run("empty", func(t *testing.T) {
assert.Empty(t, groupServices(nil, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(), noBlock))
})
t.Run("agents without services get no group", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a"}, {ID: 2, Name: "idle"}}, onlineSet(1, 2), noBlock)
assert.Equal(t, []string{"a"}, titles(groups))
assert.Equal(t, "agent-1", groups[0].Key)
assert.Equal(t, "Machine", groups[0].Kind)
assert.False(t, groups[0].Offline)
})
t.Run("services without a known agent are offline and last", func(t *testing.T) {
groups := groupServices(
[]model.Service{compose(1, nil, "orphan"), compose(2, id(7), "ghost"), compose(3, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), noBlock)
assert.Equal(t, []string{"a", "Unknown agent"}, titles(groups))
unknown := groups[1]
assert.Equal(t, "unknown", unknown.Key)
assert.True(t, unknown.Offline)
assert.Nil(t, unknown.OfflineSince)
assert.Len(t, unknown.Services, 2)
})
t.Run("an offline agent says since when", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a", Hostname: "a.lan", LastSeenAt: at(13, 51)}}, onlineSet(), noBlock)
require.Len(t, groups, 1)
assert.True(t, groups[0].Offline)
assert.Equal(t, "Agent offline", groups[0].OfflineLabel)
assert.Equal(t, at(13, 51), groups[0].OfflineSince)
assert.Equal(t, "a.lan", groups[0].Meta)
})
t.Run("a cluster is online when any manager is", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(2), noBlock)
require.Len(t, groups, 1)
assert.False(t, groups[0].Offline)
assert.Equal(t, "Swarm", groups[0].Title)
assert.Equal(t, "c1", groups[0].TitleHint)
assert.Equal(t, "Swarm cluster", groups[0].Kind)
assert.Equal(t, "managers m1, m2", groups[0].Meta)
assert.Equal(t, "swarm-c1", groups[0].Key)
})
t.Run("a cluster with no manager online has been offline since the latest manager was seen", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(), noBlock)
assert.True(t, groups[0].Offline)
assert.Equal(t, "No manager online", groups[0].OfflineLabel)
assert.Equal(t, at(11, 0), groups[0].OfflineSince)
})
t.Run("a revoked manager does not count", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "gone", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(12, 0), RevokedAt: at(12, 5)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(9, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(1), noBlock)
assert.True(t, groups[0].Offline, "the revoked manager is not connected for this purpose")
assert.Equal(t, "managers m2", groups[0].Meta)
assert.Equal(t, at(9, 0), groups[0].OfflineSince)
})
t.Run("managers are found through the rows when no agent reports the cluster", func(t *testing.T) {
agents := []model.Agent{{ID: 4, Name: "reporter", LastSeenAt: at(8, 0)}}
groups := groupServices([]model.Service{swarm(1, "c1", id(4))}, agents, onlineSet(4), noBlock)
assert.False(t, groups[0].Offline)
assert.Equal(t, "managers reporter", groups[0].Meta)
})
t.Run("order is cluster, its machines, other machines by name, unknown", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "zeta"},
{ID: 2, Name: "mgr", SwarmManager: true, SwarmClusterID: "c1"},
{ID: 3, Name: "alpha"},
{ID: 4, Name: "mgr2", SwarmManager: true, SwarmClusterID: "c1"},
}
services := []model.Service{
compose(1, id(1), "a"), compose(2, id(2), "b"), compose(3, id(3), "c"),
compose(4, id(4), "d"), compose(5, nil, "e"), swarm(6, "c1", nil),
}
groups := groupServices(services, agents, onlineSet(1, 2, 3, 4), noBlock)
assert.Equal(t, []string{"Swarm", "mgr", "mgr2", "alpha", "zeta", "Unknown agent"}, titles(groups))
assert.Equal(t, []bool{false, true, true, false, false, false}, []bool{groups[0].Sub, groups[1].Sub, groups[2].Sub, groups[3].Sub, groups[4].Sub, groups[5].Sub})
})
t.Run("two clusters carry their ids in the titles", func(t *testing.T) {
groups := groupServices([]model.Service{swarm(1, "abcdefghijkl", nil), swarm(2, "xyz", nil)}, nil, onlineSet(), noBlock)
assert.Equal(t, []string{"Swarm abcdefgh", "Swarm xyz"}, titles(groups))
assert.Equal(t, "abcdefghijkl", groups[0].TitleHint)
})
t.Run("the auto-blocked reason is kept on the row", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1),
func(model.Service) string { return "because" })
assert.Equal(t, "because", groups[0].Services[0].AutoBlocked)
})
}