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
340 lines
11 KiB
Go
340 lines
11 KiB
Go
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.
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
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{}
|
|
}
|
|
clusterRows[s.SwarmClusterID] = append(clusterRows[s.SwarmClusterID], row)
|
|
if s.AgentID != nil {
|
|
clusterRowAgents[s.SwarmClusterID][*s.AgentID] = true
|
|
}
|
|
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)
|
|
r.Digest = shortDigest(r.Digest)
|
|
return r.String()
|
|
}
|
|
|
|
// followsNoTag says the service's image reference has a digest but no tag,
|
|
// so there is no tag to poll or update.
|
|
func followsNoTag(svc model.Service) bool {
|
|
return jobs.FollowedRef(svc.ActualRef) == ""
|
|
}
|
|
|
|
// versionRow is an image version as shown on the service page.
|
|
type versionRow struct {
|
|
model.ImageVersion
|
|
Running bool // the service runs exactly this digest
|
|
}
|
|
|
|
// versionRows marks the version the service runs now, by digest.
|
|
func versionRows(versions []model.ImageVersion, actualDigest string) []versionRow {
|
|
rows := make([]versionRow, len(versions))
|
|
for i, v := range versions {
|
|
rows[i] = versionRow{ImageVersion: v, Running: v.Digest != "" && v.Digest == actualDigest}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// pollingStatus is the text of the "Polling" row on the service page and
|
|
// whether it is a registry error.
|
|
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {
|
|
switch {
|
|
case poller.SkipReason(svc) != "":
|
|
return "not polled: " + poller.SkipReason(svc), false
|
|
case svc.LastPollError != "":
|
|
return "last check failed: " + svc.LastPollError, true
|
|
}
|
|
return "every " + shortDuration(interval), false
|
|
}
|
|
|
|
// shortDuration formats d without trailing zero units: "6h", "1h30m", "90s".
|
|
func shortDuration(d time.Duration) string {
|
|
s := d.String()
|
|
if strings.HasSuffix(s, "m0s") {
|
|
s = s[:len(s)-2]
|
|
}
|
|
if strings.HasSuffix(s, "h0m") {
|
|
s = s[:len(s)-2]
|
|
}
|
|
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.
|
|
func jobDuration(j model.Job) string {
|
|
if j.StartedAt == nil {
|
|
return ""
|
|
}
|
|
end := time.Now()
|
|
if j.FinishedAt != nil {
|
|
end = *j.FinishedAt
|
|
}
|
|
return end.Sub(*j.StartedAt).Round(time.Second).String()
|
|
}
|