- icons/gen writes to the current directory so `go generate ./internal/master/web/icons` works (output verified byte-identical) - share the registry-host check as imageref.IsRegistryHost (with test) - agents fragment refreshes the "N machines" header count out of band - ignore the saved machine filter at <=760px where the machine list is hidden - aria-current on the active machine filter link, aria-pressed on theme toggle - CLAUDE.md: icons package, SSE ping event, swarm_cluster_id Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
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()
|
|
}
|