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
58 lines
1.4 KiB
Go
58 lines
1.4 KiB
Go
// Package imageref splits container image references into repository, tag
|
|
// and digest.
|
|
package imageref
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Ref is an image reference split into its parts. Tag and Digest are empty
|
|
// when the reference has none.
|
|
type Ref struct {
|
|
Repo string
|
|
Tag string
|
|
Digest string
|
|
}
|
|
|
|
// Parse splits ref into repository, tag and digest. A colon before the last
|
|
// "/" belongs to a registry host and port ("localhost:5000/foo"), not a tag.
|
|
func Parse(ref string) Ref {
|
|
var r Ref
|
|
if at := strings.Index(ref, "@"); at >= 0 {
|
|
r.Digest = ref[at+1:]
|
|
ref = ref[:at]
|
|
}
|
|
slash := strings.LastIndex(ref, "/")
|
|
last := ref[slash+1:]
|
|
if colon := strings.Index(last, ":"); colon >= 0 {
|
|
r.Tag = last[colon+1:]
|
|
ref = ref[:slash+1+colon]
|
|
}
|
|
r.Repo = ref
|
|
return r
|
|
}
|
|
|
|
// IsRegistryHost reports whether the first path segment of a repository names
|
|
// a registry: it contains a "." or ":" or is "localhost".
|
|
func IsRegistryHost(segment string) bool {
|
|
return strings.ContainsAny(segment, ".:") || segment == "localhost"
|
|
}
|
|
|
|
// String reassembles the reference as repo[:tag][@digest].
|
|
func (r Ref) String() string {
|
|
s := r.Repo
|
|
if r.Tag != "" {
|
|
s += ":" + r.Tag
|
|
}
|
|
if r.Digest != "" {
|
|
s += "@" + r.Digest
|
|
}
|
|
return s
|
|
}
|
|
|
|
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
|
|
// IsDigest reports whether d is a well-formed sha256 digest.
|
|
func IsDigest(d string) bool { return digestPattern.MatchString(d) }
|