feat(master): Ledger UI for Services and Agents (#18)
Restyles the master UI after the Ledger design: a sidebar with live counts, a machine filter, a Live box and a light/dark toggle; the overview groups services per Swarm cluster and machine with icons, search and the Icon / Icon + text / Text switch; the agents page gets a revoke popover and an add-agent panel with a connect step. Jobs, Job and Service pages get the new shell and tokens only. - fonts and Simple Icons are served by the master (no CDN) - the overview reads agents too, to show offline banners and cluster managers - revoking an agent also publishes the agents event - the SSE keepalive is a named event (ping) the Live box counts - the master passes its build version to the UI so agents on another version are marked old Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
+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 && (strings.ContainsAny(host, ".:") || host == "localhost") {
|
||||
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