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/tagver" "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-", "swarm-" 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 "" NewerTag string // the tag of NewerRef while it is still newer than the followed tag, 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.NewerTag = newerTag(s) 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:" 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) == "" } // newerTag returns the tag of the service's newer-tag suggestion, or "" when // there is none or it no longer holds. The poller writes the suggestion once // per cycle, so after the service moved to another tag the stored value can // be stale until the next cycle; checking again here hides it meanwhile. A // suggestion from another repo than the service follows now is hidden too. func newerTag(s model.Service) string { if s.RemovedAt != nil || s.NewerRef == "" { return "" } newer := imageref.Parse(s.NewerRef) repo, followed := poller.Followed(s) if newer.Repo != repo { return "" } if _, ok := tagver.Newer(followed, []string{newer.Tag}); !ok { return "" } return newer.Tag } // 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() }