feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s
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
This commit was merged in pull request #29.
This commit is contained in:
+245
-25
@@ -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 {
|
||||
Title string
|
||||
Services []serviceRow
|
||||
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{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
i, ok := index[title]
|
||||
if !ok {
|
||||
i = len(groups)
|
||||
index[title] = i
|
||||
groups = append(groups, serviceGroup{Title: title})
|
||||
}
|
||||
|
||||
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],
|
||||
}
|
||||
groups[i].Services = append(groups[i].Services, serviceRow{Service: s, AutoBlocked: autoBlocked(s)})
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user