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:
+1
-1
@@ -117,7 +117,7 @@ func run() error {
|
|||||||
|
|
||||||
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
|
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
|
||||||
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
||||||
PollInterval: cfg.PollInterval,
|
PollInterval: cfg.PollInterval, Version: version,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvents_SendsPingWhenIdle(t *testing.T) {
|
||||||
|
old := pingInterval
|
||||||
|
pingInterval = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { pingInterval = old })
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
if strings.HasPrefix(line, "event:") {
|
||||||
|
assert.Equal(t, "event: ping\n", line, "the Live box counts a named event; a comment would not reach the browser's listeners")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAgent_ValidationShowsInsideTheAddPanel(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-2"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), `class="drawer"`)
|
||||||
|
assert.Contains(t, string(body), `value="node-2"`, "the typed name is kept")
|
||||||
|
assert.NotContains(t, string(body), "pua_", "no token is shown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavFragment(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
cases := []struct {
|
||||||
|
query, want, notWant string
|
||||||
|
}{
|
||||||
|
{"?section=agents", `<a href="/agents" class="cur" aria-current="page">Agents <span class="n">1/3</span>`, `<a href="/" class="cur"`},
|
||||||
|
{"?section=services", `<a href="/" class="cur" aria-current="page">Services <span class="n">4</span>`, `<a href="/agents" class="cur"`},
|
||||||
|
{"?section=bogus", `<a href="/jobs">Jobs</a>`, `class="cur"`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.query, func(t *testing.T) {
|
||||||
|
code, body := get(t, srv, "/fragments/nav"+c.query)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.NotContains(t, body, "<html")
|
||||||
|
assert.Contains(t, body, c.want)
|
||||||
|
assert.NotContains(t, body, c.notWant)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Contains(t, body, `hx-get="/fragments/nav?section=agents"`, "every page refreshes the sidebar on services and agents events")
|
||||||
|
assert.Contains(t, body, `hx-trigger="sse:services, sse:agents"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentConnectFragment(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
online bool
|
||||||
|
wantCode int
|
||||||
|
want []string
|
||||||
|
notWant []string
|
||||||
|
}{
|
||||||
|
{"never connected waits", "/fragments/agents/5/connect", false, http.StatusOK, []string{"Waiting for fresh", `action="/agents/5/revoke"`}, []string{"Connected", "add-steps"}},
|
||||||
|
{"online is connected", "/fragments/agents/5/connect", true, http.StatusOK, []string{"Connected · ", `id="add-steps" hx-swap-oob="innerHTML"`}, []string{"Waiting", "revoke"}},
|
||||||
|
{"seen before is connected", "/fragments/agents/3/connect", false, http.StatusOK, []string{"Connected · node-3.lan · v1.3.2"}, []string{"Waiting"}},
|
||||||
|
{"unknown", "/fragments/agents/99/connect", false, http.StatusNotFound, nil, nil},
|
||||||
|
{"revoked", "/fragments/agents/4/connect", false, http.StatusNotFound, nil, nil},
|
||||||
|
{"bad id", "/fragments/agents/abc/connect", false, http.StatusBadRequest, nil, nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
srv, _, hub, _, _ := newTestEnv(t)
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.online[5] = c.online
|
||||||
|
hub.mu.Unlock()
|
||||||
|
code, body := get(t, srv, c.path)
|
||||||
|
assert.Equal(t, c.wantCode, code)
|
||||||
|
for _, w := range c.want {
|
||||||
|
assert.Contains(t, body, w)
|
||||||
|
}
|
||||||
|
for _, w := range c.notWant {
|
||||||
|
assert.NotContains(t, body, w)
|
||||||
|
}
|
||||||
|
assert.NotContains(t, body, "pua_", "the token is never sent again")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatic_FontsAndAppJS(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
for _, path := range []string{
|
||||||
|
"/static/app.js", "/static/app.css",
|
||||||
|
"/static/schibsted-grotesk-latin-wght-normal.woff2", "/static/jetbrains-mono-latin-wght-normal.woff2",
|
||||||
|
"/static/OFL-schibsted-grotesk.txt", "/static/OFL-jetbrains-mono.txt",
|
||||||
|
} {
|
||||||
|
code, _ := get(t, srv, path)
|
||||||
|
assert.Equal(t, http.StatusOK, code, path)
|
||||||
|
}
|
||||||
|
_, css := get(t, srv, "/static/app.css")
|
||||||
|
assert.NotContains(t, css, "fonts.googleapis.com", "fonts are served by the master")
|
||||||
|
assert.Contains(t, css, "format('woff2')")
|
||||||
|
_, page := get(t, srv, "/")
|
||||||
|
assert.NotContains(t, page, "fonts.googleapis.com")
|
||||||
|
assert.Contains(t, page, `src="/static/app.js"`)
|
||||||
|
}
|
||||||
+190
-29
@@ -10,6 +10,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||||
"github.com/shcizo/package-updater/internal/master/events"
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/web/icons"
|
||||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,12 +74,14 @@ type Broker interface {
|
|||||||
Publish(t events.Topic)
|
Publish(t events.Topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds values shown in the "add agent" snippet and on the service page.
|
// Config holds values shown in the "add agent" snippet, on the agents page and
|
||||||
|
// on the service page.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||||
MasterKey string // the master's fingerprint
|
MasterKey string // the master's fingerprint
|
||||||
AgentImage string
|
AgentImage string
|
||||||
PollInterval time.Duration // shown as "every <interval>" on the service page
|
PollInterval time.Duration // shown as "every <interval>" on the service page
|
||||||
|
Version string // the master's build version; agents on another version are marked old
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server renders the UI.
|
// Server renders the UI.
|
||||||
@@ -95,9 +99,9 @@ type Server struct {
|
|||||||
// New parses the templates. Each page is its own template set so every page
|
// New parses the templates. Each page is its own template set so every page
|
||||||
// can define its own "content" block.
|
// can define its own "content" block.
|
||||||
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
||||||
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
|
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
|
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
|
||||||
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse %s: %w", page, err)
|
return nil, fmt.Errorf("parse %s: %w", page, err)
|
||||||
@@ -124,20 +128,57 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
|
|||||||
route("GET /fragments/jobs/{id}", s.jobFragment)
|
route("GET /fragments/jobs/{id}", s.jobFragment)
|
||||||
route("GET /agents", s.agents)
|
route("GET /agents", s.agents)
|
||||||
route("GET /fragments/agents", s.agentsFragment)
|
route("GET /fragments/agents", s.agentsFragment)
|
||||||
|
route("GET /fragments/agents/{id}/connect", s.agentConnectFragment)
|
||||||
|
route("GET /fragments/nav", s.navFragment)
|
||||||
route("POST /agents", s.createAgent)
|
route("POST /agents", s.createAgent)
|
||||||
route("POST /agents/{id}/revoke", s.revokeAgent)
|
route("POST /agents/{id}/revoke", s.revokeAgent)
|
||||||
route("GET /events", s.events)
|
route("GET /events", s.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navData is what the sidebar shows on every page.
|
||||||
|
type navData struct {
|
||||||
|
Section string // services, jobs or agents; "" for none
|
||||||
|
Services int
|
||||||
|
AgentsOnline int
|
||||||
|
AgentsTotal int // agents that are not revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
// spriteIcon is one icon of the sprite the services fragment carries.
|
||||||
|
type spriteIcon struct {
|
||||||
|
Slug string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sections maps a page to the sidebar entry that is current on it.
|
||||||
|
var sections = map[string]string{
|
||||||
|
"overview.html": "services", "service.html": "services",
|
||||||
|
"jobs.html": "jobs", "job.html": "jobs",
|
||||||
|
"agents.html": "agents",
|
||||||
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
CSRF string
|
CSRF string
|
||||||
Groups []serviceGroup
|
Nav navData
|
||||||
|
Groups []serviceGroup
|
||||||
|
// ServiceCount is how many services the groups hold.
|
||||||
|
ServiceCount int
|
||||||
|
// Sprite holds the icons the groups use, so the page carries only those.
|
||||||
|
Sprite []spriteIcon
|
||||||
Agents []agentRow
|
Agents []agentRow
|
||||||
Error string
|
Error string
|
||||||
Created model.Agent
|
Created model.Agent
|
||||||
Snippet string
|
Snippet string
|
||||||
|
|
||||||
|
// AddStep is the step of the "add agent" panel: 0 closed, 1 name,
|
||||||
|
// 2 deploy (Created, Token, MasterKey and Snippet are set).
|
||||||
|
AddStep int
|
||||||
|
FormName string // what was typed in step 1
|
||||||
|
Token string
|
||||||
|
MasterKey string
|
||||||
|
Connected bool // the agent Created has connected
|
||||||
|
MasterVersion string // the master's build version; "" if unknown
|
||||||
|
|
||||||
Service model.Service
|
Service model.Service
|
||||||
ConfigFiles []string
|
ConfigFiles []string
|
||||||
Reason string // why Service cannot be updated; "" when it can
|
Reason string // why Service cannot be updated; "" when it can
|
||||||
@@ -154,6 +195,58 @@ func (s *Server) base(r *http.Request, title string) pageData {
|
|||||||
return pageData{Title: title, CSRF: sess.CSRFToken}
|
return pageData{Title: title, CSRF: sess.CSRFToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// page renders a full page: the sidebar numbers are looked up and put in data.
|
||||||
|
func (s *Server) page(w http.ResponseWriter, r *http.Request, status int, page string, data pageData) {
|
||||||
|
nav, err := s.navData(r.Context(), sections[page])
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Nav = nav
|
||||||
|
s.render(w, status, page, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// navData counts what the sidebar shows next to its entries.
|
||||||
|
func (s *Server) navData(ctx context.Context, section string) (navData, error) {
|
||||||
|
nav := navData{Section: section}
|
||||||
|
services, err := s.store.ListServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nav, err
|
||||||
|
}
|
||||||
|
nav.Services = len(services)
|
||||||
|
agents, err := s.store.ListAgents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nav, err
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
if a.RevokedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nav.AgentsTotal++
|
||||||
|
if s.hub.Online(a.ID) {
|
||||||
|
nav.AgentsOnline++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nav, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// navFragment renders the sidebar entries. The page asks for it with its own
|
||||||
|
// section so the current entry stays marked.
|
||||||
|
func (s *Server) navFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
section := r.URL.Query().Get("section")
|
||||||
|
if section != "services" && section != "jobs" && section != "agents" {
|
||||||
|
section = ""
|
||||||
|
}
|
||||||
|
nav, err := s.navData(r.Context(), section)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.base(r, "")
|
||||||
|
data.Nav = nav
|
||||||
|
s.render(w, http.StatusOK, "overview.html", "nav", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
|
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -168,17 +261,41 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
data.Groups = groupServices(services, s.autoBlocked)
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
data.Groups = groupServices(services, agents, s.hub.Online, s.autoBlocked)
|
||||||
|
data.ServiceCount = len(services)
|
||||||
|
data.Sprite = spriteFor(data.Groups)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spriteFor collects the icons the groups' rows use, each once.
|
||||||
|
func spriteFor(groups []serviceGroup) []spriteIcon {
|
||||||
|
var sprite []spriteIcon
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, row := range g.Services {
|
||||||
|
if row.Icon == "" || seen[row.Icon] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.Icon] = true
|
||||||
|
if path, ok := icons.Path(row.Icon); ok {
|
||||||
|
sprite = append(sprite, spriteIcon{Slug: row.Icon, Path: path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.serviceData(r)
|
data, err := s.serviceData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "overview.html", "layout", data)
|
s.page(w, r, http.StatusOK, "overview.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -187,7 +304,7 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "overview.html", "services", data)
|
s.render(w, http.StatusOK, "overview.html", "services-fragment", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
|
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
|
||||||
@@ -255,7 +372,7 @@ func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.failLookup(w, err)
|
s.failLookup(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "service.html", "layout", data)
|
s.page(w, r, http.StatusOK, "service.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -302,7 +419,7 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.Error = "Cannot update: " + nu.Reason + "."
|
data.Error = "Cannot update: " + nu.Reason + "."
|
||||||
s.render(w, http.StatusConflict, "service.html", "layout", data)
|
s.page(w, r, http.StatusConflict, "service.html", data)
|
||||||
default:
|
default:
|
||||||
s.failLookup(w, err)
|
s.failLookup(w, err)
|
||||||
}
|
}
|
||||||
@@ -337,7 +454,7 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.Error = "Cannot enable auto: " + nu.Reason + "."
|
data.Error = "Cannot enable auto: " + nu.Reason + "."
|
||||||
s.render(w, http.StatusConflict, "service.html", "layout", data)
|
s.page(w, r, http.StatusConflict, "service.html", data)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
@@ -369,7 +486,7 @@ func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "jobs.html", "layout", data)
|
s.page(w, r, http.StatusOK, "jobs.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -400,7 +517,7 @@ func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool
|
|||||||
|
|
||||||
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if data, ok := s.jobData(w, r); ok {
|
if data, ok := s.jobData(w, r); ok {
|
||||||
s.render(w, http.StatusOK, "job.html", "layout", data)
|
s.page(w, r, http.StatusOK, "job.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,23 +529,28 @@ func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
||||||
data := s.base(r, "Agents")
|
data := s.base(r, "Agents")
|
||||||
|
data.MasterVersion = s.cfg.Version
|
||||||
agents, err := s.store.ListAgents(r.Context())
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
|
data.Agents = append(data.Agents, newAgentRow(a, s.hub.Online(a.ID), s.cfg.Version))
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agents shows the agent list; with ?add=1 the "add agent" panel is open.
|
||||||
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.agentData(r)
|
data, err := s.agentData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "agents.html", "layout", data)
|
if r.URL.Query().Get("add") == "1" {
|
||||||
|
data.AddStep = 1
|
||||||
|
}
|
||||||
|
s.page(w, r, http.StatusOK, "agents.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -440,10 +562,34 @@ func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, http.StatusOK, "agents.html", "agents", data)
|
s.render(w, http.StatusOK, "agents.html", "agents", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentConnectFragment is the last step of the "add agent" panel: it says
|
||||||
|
// whether the agent that was just added has connected. It never repeats the
|
||||||
|
// token.
|
||||||
|
func (s *Server) agentConnectFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(agents, func(a model.Agent) bool { return a.ID == id && a.RevokedAt == nil })
|
||||||
|
if i < 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.base(r, "Agents")
|
||||||
|
data.Created = agents[i]
|
||||||
|
data.Connected = s.hub.Online(id) || agents[i].LastSeenAt != nil
|
||||||
|
s.render(w, http.StatusOK, "agents.html", "connect", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
if !agentNamePattern.MatchString(name) {
|
if !agentNamePattern.MatchString(name) {
|
||||||
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
|
s.agentsWithError(w, r, http.StatusBadRequest, name, "Agent names use lowercase letters, digits and dashes.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := agenttoken.Generate()
|
token, err := agenttoken.Generate()
|
||||||
@@ -453,28 +599,39 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
||||||
if errors.Is(err, model.ErrConflict) {
|
if errors.Is(err, model.ErrConflict) {
|
||||||
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
|
s.agentsWithError(w, r, http.StatusConflict, name, fmt.Sprintf("An agent named %q already exists.", name))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := s.base(r, "Agent created")
|
|
||||||
data.Created = agent
|
|
||||||
data.Snippet = agentSnippet(s.cfg, token)
|
|
||||||
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
|
|
||||||
s.render(w, http.StatusOK, "agent_created.html", "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
|
||||||
data, err := s.agentData(r)
|
data, err := s.agentData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data.AddStep = 2
|
||||||
|
data.Created = agent
|
||||||
|
data.Token = token
|
||||||
|
data.MasterKey = s.cfg.MasterKey
|
||||||
|
data.Snippet = agentSnippet(s.cfg, token)
|
||||||
|
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
|
||||||
|
s.page(w, r, http.StatusOK, "agents.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentsWithError shows the agent list with the "add agent" panel open at
|
||||||
|
// its first step, name kept and msg under the field.
|
||||||
|
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, name, msg string) {
|
||||||
|
data, err := s.agentData(r)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.AddStep = 1
|
||||||
|
data.FormName = name
|
||||||
data.Error = msg
|
data.Error = msg
|
||||||
s.render(w, status, "agents.html", "layout", data)
|
s.page(w, r, status, "agents.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -489,6 +646,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.hub.Disconnect(id)
|
s.hub.Disconnect(id)
|
||||||
|
s.broker.Publish(events.Agents) // the agent list and the sidebar change
|
||||||
s.broker.Publish(events.Services) // the agent's services were marked removed
|
s.broker.Publish(events.Services) // the agent's services were marked removed
|
||||||
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
|
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
|
||||||
for _, jobID := range failedJobs {
|
for _, jobID := range failedJobs {
|
||||||
@@ -497,6 +655,9 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pingInterval is how often an idle event stream sends "event: ping".
|
||||||
|
var pingInterval = 25 * time.Second
|
||||||
|
|
||||||
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
||||||
// turns "event: services" into a refetch of the services fragment. The event
|
// turns "event: services" into a refetch of the services fragment. The event
|
||||||
// carries no state, so a dropped signal is recovered by the next one.
|
// carries no state, so a dropped signal is recovered by the next one.
|
||||||
@@ -516,7 +677,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.log.Error("sse_flush_unsupported", "error", err)
|
s.log.Error("sse_flush_unsupported", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keepalive := time.NewTicker(25 * time.Second)
|
keepalive := time.NewTicker(pingInterval)
|
||||||
defer keepalive.Stop()
|
defer keepalive.Stop()
|
||||||
for {
|
for {
|
||||||
var msg string
|
var msg string
|
||||||
@@ -526,7 +687,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
case t := <-ch:
|
case t := <-ch:
|
||||||
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
||||||
case <-keepalive.C:
|
case <-keepalive.C:
|
||||||
msg = ": keepalive\n\n"
|
msg = "event: ping\ndata: ok\n\n" // keeps proxies open; the Live box in the sidebar counts it
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprint(w, msg); err != nil {
|
if _, err := fmt.Fprint(w, msg); err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -211,30 +211,46 @@ func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.B
|
|||||||
// the service page shows.
|
// the service page shows.
|
||||||
func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
agentID := int64(2)
|
return newTestEnvConfig(t, func(c *Config) { c.PollInterval = pollInterval })
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvVersion is newTestEnv with version as the master's build version.
|
||||||
|
func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
|
t.Helper()
|
||||||
|
return newTestEnvConfig(t, func(c *Config) { c.Version = version })
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvConfig is newTestEnv with edit applied to the UI's Config.
|
||||||
|
func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
|
t.Helper()
|
||||||
|
agentID, offlineAgentID := int64(2), int64(3)
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
created: map[string]string{"node-2": "x"},
|
created: map[string]string{"node-2": "x"},
|
||||||
services: []model.Service{
|
services: []model.Service{
|
||||||
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
||||||
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
||||||
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", SwarmClusterID: "c1abcdefgh",
|
||||||
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
||||||
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||||
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
||||||
|
{ID: 4, Kind: protocol.KindCompose, ComposeProject: "media", ComposeService: "radarr", AgentID: &offlineAgentID, AgentName: "node-3",
|
||||||
|
ActualRef: "lscr.io/linuxserver/radarr:latest", Status: "running", UpdateMode: "manual"},
|
||||||
},
|
},
|
||||||
agents: []model.Agent{
|
agents: []model.Agent{
|
||||||
{ID: 2, Name: "node-2", Hostname: "node-2"},
|
{ID: 2, Name: "node-2", Hostname: "node-2.lan", AgentVersion: "v1.4.0", SwarmManager: true, SwarmClusterID: "c1abcdefgh", LastSeenAt: ptr(time.Now().UTC())},
|
||||||
{ID: 3, Name: "node-3"},
|
{ID: 3, Name: "node-3", Hostname: "node-3.lan", AgentVersion: "v1.3.2", LastSeenAt: ptr(time.Date(2026, 10, 12, 13, 51, 0, 0, time.UTC))},
|
||||||
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
||||||
|
{ID: 5, Name: "fresh"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
hub := &fakeHub{online: map[int64]bool{2: true}}
|
hub := &fakeHub{online: map[int64]bool{2: true}}
|
||||||
broker := events.NewBroker()
|
broker := events.NewBroker()
|
||||||
jobs := &fakeJobs{}
|
jobs := &fakeJobs{}
|
||||||
s, err := New(store, hub, jobs, broker, Config{
|
cfg := Config{
|
||||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||||
PollInterval: pollInterval,
|
}
|
||||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
edit(&cfg)
|
||||||
|
s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.Register(mux, func(h http.Handler) http.Handler {
|
s.Register(mux, func(h http.Handler) http.Handler {
|
||||||
@@ -269,13 +285,44 @@ func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
|
|||||||
code, body := get(t, srv, "/")
|
code, body := get(t, srv, "/")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
assert.Contains(t, body, "<html")
|
assert.Contains(t, body, "<html")
|
||||||
assert.Contains(t, body, "node-2")
|
assert.Contains(t, body, ">Swarm</h2>", "one cluster is just called Swarm")
|
||||||
assert.Contains(t, body, "Swarm")
|
assert.Contains(t, body, `title="c1abcdefgh"`, "the full cluster id is the hover text")
|
||||||
assert.Contains(t, body, "slusk/web")
|
assert.Contains(t, body, "managers node-2")
|
||||||
assert.Contains(t, body, "jellyfin_jellyfin")
|
assert.Contains(t, body, ">node-2</h2>")
|
||||||
|
assert.Contains(t, body, `<span class="nmt">web</span>`)
|
||||||
|
assert.Contains(t, body, `<span class="stk">slusk</span>`)
|
||||||
|
assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are")
|
||||||
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
||||||
assert.Contains(t, body, "managed outside")
|
assert.Contains(t, body, "Managed outside")
|
||||||
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
|
assert.Less(t, strings.Index(body, ">Swarm</h2>"), strings.Index(body, ">node-2</h2>"), "a cluster is followed by its managers' machines")
|
||||||
|
assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_OfflineGroupShowsSince(t *testing.T) {
|
||||||
|
srv, store, hub, _, _ := newTestEnv(t)
|
||||||
|
_, body := get(t, srv, "/")
|
||||||
|
assert.Regexp(t, `Agent offline since <time datetime="2026-10-12T13:51:00Z">13:51</time> · showing last known state`, body)
|
||||||
|
assert.NotContains(t, body, "No manager online", "the cluster has a connected manager")
|
||||||
|
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.online[2] = false
|
||||||
|
hub.mu.Unlock()
|
||||||
|
_, body = get(t, srv, "/")
|
||||||
|
since := store.agents[0].LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
assert.Contains(t, body, `No manager online since <time datetime="`+since+`">`)
|
||||||
|
assert.Contains(t, body, ` · showing last known state`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_IconOrLetter(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ActualRef = "gitea/gitea:1.27.3"
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Contains(t, body, `<symbol id="si-gitea"`, path)
|
||||||
|
assert.Equal(t, 1, strings.Count(body, `<symbol id="si-gitea"`), "the sprite holds each icon once: "+path)
|
||||||
|
assert.Contains(t, body, `href="#si-gitea"`, path)
|
||||||
|
assert.Contains(t, body, `class="ltr lc-`, "services without an icon get a letter: "+path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
||||||
@@ -283,15 +330,57 @@ func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
|||||||
code, body := get(t, srv, "/fragments/services")
|
code, body := get(t, srv, "/fragments/services")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
assert.NotContains(t, body, "<html")
|
assert.NotContains(t, body, "<html")
|
||||||
assert.Contains(t, body, "slusk/web")
|
assert.Contains(t, body, `title="slusk/web"`)
|
||||||
|
assert.Contains(t, body, `id="machines" hx-swap-oob`, "the machine filter in the sidebar is refreshed with the rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
||||||
srv, _, _, _ := newTestServer(t)
|
srv, _, _, _ := newTestServer(t)
|
||||||
_, body := get(t, srv, "/agents")
|
_, body := get(t, srv, "/agents")
|
||||||
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
|
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*Online`), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
|
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*Offline`), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
|
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*Revoked`), body)
|
||||||
|
assert.Regexp(t, regexp.MustCompile(`fresh[\s\S]*Never connected`), body)
|
||||||
|
assert.Contains(t, body, "Manager")
|
||||||
|
assert.Contains(t, body, "3 machines", "revoked agents are not counted")
|
||||||
|
assert.NotContains(t, body, `class="drawer"`, "the add panel is closed by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_RevokeUsesPopover(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Contains(t, body, `<details class="rv" data-id="2">`)
|
||||||
|
assert.Contains(t, body, `action="/agents/2/revoke"`)
|
||||||
|
assert.NotContains(t, body, "confirm(")
|
||||||
|
assert.NotContains(t, body, `action="/agents/4/revoke"`, "a revoked agent cannot be revoked again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_MasterVersionAndOldAgent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, version string
|
||||||
|
wantOld bool
|
||||||
|
}{
|
||||||
|
{"other version is old", "v1.4.0", true},
|
||||||
|
{"dev master marks nothing", "dev", false},
|
||||||
|
{"unknown master version marks nothing", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
srv, _, _, _, _ := newTestEnvVersion(t, c.version)
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Equal(t, c.version != "", strings.Contains(body, "master "+c.version))
|
||||||
|
assert.Equal(t, c.wantOld, strings.Contains(body, `<div class="ver old">`+"<span class=\"k\">Agent</span>v1.3.2"), "node-3 runs v1.3.2")
|
||||||
|
assert.NotContains(t, body, `<div class="ver old"><span class="k">Agent</span>v1.4.0`, "node-2 is current")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_AddPanelOpensWithQuery(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
_, body := get(t, srv, "/agents?add=1")
|
||||||
|
assert.Contains(t, body, `class="drawer"`)
|
||||||
|
assert.Contains(t, body, `<form method="post" action="/agents"`)
|
||||||
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
||||||
@@ -309,6 +398,10 @@ func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
|||||||
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
|
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
|
||||||
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
|
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
|
||||||
assert.Contains(t, string(body), "shown only once")
|
assert.Contains(t, string(body), "shown only once")
|
||||||
|
assert.Contains(t, string(body), `<pre id="snippet">`)
|
||||||
|
assert.Contains(t, string(body), `data-copy="`+token+`"`)
|
||||||
|
assert.Contains(t, string(body), `hx-get="/fragments/agents/9/connect"`, "step 3 waits for the agent")
|
||||||
|
assert.Contains(t, string(body), ">node-3</b>", "the agent list is still on the page")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAgent_Validation(t *testing.T) {
|
func TestCreateAgent_Validation(t *testing.T) {
|
||||||
@@ -348,16 +441,16 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
|
|||||||
assert.Equal(t, int64(2), store.revoked())
|
assert.Equal(t, int64(2), store.revoked())
|
||||||
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
||||||
var got []events.Topic
|
var got []events.Topic
|
||||||
for len(got) < 4 {
|
for len(got) < 5 {
|
||||||
select {
|
select {
|
||||||
case topic := <-ch:
|
case topic := <-ch:
|
||||||
got = append(got, topic)
|
got = append(got, topic)
|
||||||
default:
|
default:
|
||||||
t.Fatalf("revoke published %v, want services, jobs and one topic per failed job", got)
|
t.Fatalf("revoke published %v, want agents, services, jobs and one topic per failed job", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
|
assert.ElementsMatch(t, []events.Topic{events.Agents, events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
|
||||||
"the overview drops the agent's services; job lists and open job pages show the failures")
|
"the agent list and sidebar change; the overview drops the agent's services; job lists and open job pages show the failures")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRevokeAgent_BadID(t *testing.T) {
|
func TestRevokeAgent_BadID(t *testing.T) {
|
||||||
@@ -640,16 +733,19 @@ func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
|
|||||||
srv, store, _, _, jobs := newTestEnv(t)
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
store.services[1].UpdateMode = "auto"
|
store.services[1].UpdateMode = "auto"
|
||||||
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
for _, path := range []string{"/", "/fragments/services"} {
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
_, body := get(t, srv, path)
|
_, body := get(t, srv, path)
|
||||||
assert.Contains(t, body, `action="/services/1/mode"`, path)
|
assert.Contains(t, body, `action="/services/1/mode"`, path)
|
||||||
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
|
||||||
assert.Contains(t, body, `name="return" value="overview"`, path)
|
assert.Contains(t, body, `name="return" value="overview"`, path)
|
||||||
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
|
assert.Contains(t, body, `class="tag f-up" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Update available<em>1.5.0</em>`, path)
|
||||||
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`+"\n"+` <button type="submit" class="sw" role="switch" aria-checked="true">`, "auto services toggle to manual: "+path)
|
||||||
assert.Contains(t, body, `disabled>Make auto</button> <span class="muted">`+model.ReasonSelf+`</span>`, "blocked manual services get a disabled button: "+path)
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`, "manual services toggle to auto: "+path)
|
||||||
|
assert.Contains(t, body, `aria-checked="false" disabled title="`+model.ReasonBuilt+`">`, "blocked manual services get a disabled switch: "+path)
|
||||||
|
assert.Contains(t, body, `<span class="na">`+model.ReasonBuilt+`</span>`, "and say why: "+path)
|
||||||
|
assert.Contains(t, body, `<span class="na">n/a</span>`, "services managed outside have no switch: "+path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,219 @@
|
|||||||
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
|
@font-face{font-family:'Schibsted Grotesk';font-style:normal;font-weight:400 900;font-display:swap;src:url(/static/schibsted-grotesk-latin-wght-normal.woff2) format('woff2')}
|
||||||
@media (prefers-color-scheme: light) {
|
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url(/static/jetbrains-mono-latin-wght-normal.woff2) format('woff2')}
|
||||||
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
|
|
||||||
|
/* Ledger. Tokens live on <html class="app">; a script in <head> adds t-light before first paint. */
|
||||||
|
.app{color-scheme:dark;--bg:#0f1218;--side:#0b0d12;--card:#151a23;--hover:#171c26;--line:#232a37;--line2:#323a4a;--fg:#e7eaf1;--mut:#a0a9bb;--dim:#828ba0;--blue:#74a8ff;--bluebg:rgba(116,168,255,.12);--orange:#ffa262;--orangebg:rgba(255,162,98,.12);--green:#62d4a0;--red:#ff7d88;--redbg:rgba(255,125,136,.10);--violet:#c5a9ff;--violetbg:rgba(197,169,255,.12);--l0:#74a8ff;--l1:#c5a9ff;--l2:#5fd0e0;--l3:#f29bd0;--onblue:#06142e;--term:#080a0e;--shadow:0 12px 32px rgba(0,0,0,.5);--sans:'Schibsted Grotesk',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.45 var(--sans)}
|
||||||
|
.app.t-light{color-scheme:light;--bg:#f7f8fa;--side:#eceff4;--card:#ffffff;--hover:#f0f2f6;--line:#dde1e8;--line2:#c6ccd7;--fg:#121620;--mut:#4b5468;--dim:#5c6579;--blue:#2156c8;--bluebg:rgba(33,86,200,.08);--orange:#a84b0b;--orangebg:rgba(200,96,20,.09);--green:#177649;--red:#b32a3b;--redbg:rgba(179,42,59,.07);--violet:#6943c1;--violetbg:rgba(105,67,193,.08);--l0:#2156c8;--l1:#6943c1;--l2:#0b7285;--l3:#a8306f;--onblue:#ffffff;--term:#121620;--shadow:0 12px 32px rgba(20,30,50,.16)}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0}
|
||||||
|
[hidden]{display:none !important}
|
||||||
|
a{color:var(--blue)}a:hover{color:var(--fg)}
|
||||||
|
button,input{font-family:inherit}
|
||||||
|
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
||||||
|
|
||||||
|
/* Shell */
|
||||||
|
.shell{display:flex;flex-wrap:wrap;min-height:100vh}
|
||||||
|
.side{flex:0 0 232px;background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:28px;padding:20px 14px}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;font:700 15px var(--sans);letter-spacing:-.01em;padding:0 8px;color:var(--fg);text-decoration:none}
|
||||||
|
.brand svg{color:var(--blue)}
|
||||||
|
.snav{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.snav a,.mlist a{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;color:var(--mut);text-decoration:none;font-weight:500}
|
||||||
|
.snav a:hover,.mlist a:hover{background:var(--hover);color:var(--fg)}
|
||||||
|
.snav a.cur,.mlist a.cur{background:var(--card);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line)}
|
||||||
|
.snav .n,.mlist .n{margin-left:auto;font:12px var(--mono);color:var(--dim)}
|
||||||
|
.slabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 8px 6px}
|
||||||
|
.mlist{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.mlist .sub{padding-left:22px;font-size:13px}
|
||||||
|
.mdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
|
||||||
|
.mdot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--red)}
|
||||||
|
.sw-icon{width:7px;height:7px;flex:none;border-radius:2px;box-shadow:inset 0 0 0 1.5px var(--blue)}
|
||||||
|
.livebox{margin-top:auto;padding:10px 8px;border-top:1px solid var(--line);font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px}
|
||||||
|
.livebox b{display:flex;align-items:center;gap:8px;color:var(--fg);font-weight:600}
|
||||||
|
.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);animation:breathe 2.4s ease-in-out infinite}
|
||||||
|
.livebox.down .pulse{background:var(--red);animation:none}
|
||||||
|
@keyframes breathe{0%{box-shadow:0 0 0 0 rgba(98,212,160,.45)}50%{box-shadow:0 0 0 5px rgba(98,212,160,0)}}
|
||||||
|
.sfoot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}
|
||||||
|
.sfoot form{margin:0}
|
||||||
|
.work{flex:999 1 640px;min-width:0;display:flex;flex-wrap:wrap}
|
||||||
|
.main{flex:999 1 520px;min-width:0;padding:28px 36px 48px;container-type:inline-size}
|
||||||
|
@container (max-width:760px){
|
||||||
|
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
|
||||||
|
}
|
||||||
|
.sprite{position:absolute;width:0;height:0;overflow:hidden}
|
||||||
|
|
||||||
|
/* Page header, buttons */
|
||||||
|
.hdr{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 24px;margin-bottom:18px}
|
||||||
|
.hdr h1{margin:0;font:700 34px/1 var(--sans);letter-spacing:-.025em}
|
||||||
|
.hdr .sub{font-size:14px;color:var(--mut)}
|
||||||
|
.hdr .end{margin-left:auto}
|
||||||
|
.btn,button:not([class]){height:32px;padding:0 14px;border-radius:6px;border:1px solid var(--line2);background:var(--card);color:var(--fg);font:600 13px var(--sans);cursor:pointer;display:inline-flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}
|
||||||
|
.btn:hover,button:not([class]):hover{border-color:var(--dim);color:var(--fg)}
|
||||||
|
button:not([class]):disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.btn.pri,.btn.pri:hover{background:var(--blue);border-color:var(--blue);color:var(--onblue)}
|
||||||
|
.btn.sm{height:28px;padding:0 10px;font-size:12.5px}
|
||||||
|
.btn.quiet{border-color:transparent;background:transparent;color:var(--mut)}
|
||||||
|
.btn.quiet:hover{color:var(--fg)}
|
||||||
|
.btn.red{color:var(--red);border-color:var(--line2);background:transparent}
|
||||||
|
.btn.redfill,.btn.redfill:hover{background:var(--red);border-color:var(--red);color:#1d0408}
|
||||||
|
summary.btn{list-style:none}
|
||||||
|
summary.btn::-webkit-details-marker{display:none}
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:12px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:8px}
|
||||||
|
.search{flex:0 1 340px;min-width:220px;display:flex;align-items:center;gap:8px;height:34px;padding:0 12px;border-radius:6px;background:var(--card);border:1px solid var(--line2);color:var(--dim)}
|
||||||
|
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font-size:14px}
|
||||||
|
.search input::placeholder{color:var(--dim)}
|
||||||
|
.tools .end{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||||
|
.tools .lbl{font-size:12.5px;color:var(--dim)}
|
||||||
|
.seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
|
||||||
|
.seg button:hover{color:var(--fg)}
|
||||||
|
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.grp{margin-top:28px}
|
||||||
|
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
|
||||||
|
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
||||||
|
.gh .kind{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--mut)}
|
||||||
|
.gh .meta{font-size:13px;color:var(--dim)}
|
||||||
|
.gh .warn{font-size:13px;color:var(--red);font-weight:500}
|
||||||
|
.gh .count{margin-left:auto;font:12px var(--mono);color:var(--dim)}
|
||||||
|
.grp.offline .gh{border-bottom-color:var(--line2)}
|
||||||
|
.grp.offline .row{color:var(--dim)}
|
||||||
|
.row{position:relative;display:grid;grid-template-columns:minmax(200px,1.4fr) minmax(200px,2fr) 130px minmax(160px,1.5fr) minmax(104px,150px);align-items:center;column-gap:18px;min-height:42px;padding:0 6px;border-bottom:1px solid var(--line)}
|
||||||
|
.row:hover{background:var(--hover)}
|
||||||
|
.nm{display:flex;align-items:center;gap:8px;min-width:0}
|
||||||
|
.nm a{display:inline-flex;align-items:center;gap:10px;min-width:0;color:var(--fg);font-weight:600;text-decoration:none;white-space:nowrap}
|
||||||
|
.nm a:hover .nmt{text-decoration:underline;text-underline-offset:3px}
|
||||||
|
.nm .nmt{overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.nm .stk{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ico{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2);color:var(--fg)}
|
||||||
|
.ico svg{fill:currentColor}
|
||||||
|
.ltr{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font:700 12.5px var(--sans)}
|
||||||
|
.lc-0{color:var(--l0);background:color-mix(in srgb,var(--l0) 18%,transparent)}
|
||||||
|
.lc-1{color:var(--l1);background:color-mix(in srgb,var(--l1) 18%,transparent)}
|
||||||
|
.lc-2{color:var(--l2);background:color-mix(in srgb,var(--l2) 18%,transparent)}
|
||||||
|
.lc-3{color:var(--l3);background:color-mix(in srgb,var(--l3) 18%,transparent)}
|
||||||
|
.grp.offline .ico,.grp.offline .ltr{opacity:.65}
|
||||||
|
.d-icon .nmt,.d-icon .stk{display:none}
|
||||||
|
.d-text .ico,.d-text .ltr{display:none}
|
||||||
|
.ver{display:flex;min-width:0;font:12.5px var(--mono);color:var(--dim);white-space:nowrap}
|
||||||
|
.ver .r{overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||||
|
.ver .t{flex:none}
|
||||||
|
.ver b{color:var(--fg);font-weight:500}
|
||||||
|
.st{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500;white-space:nowrap}
|
||||||
|
.st svg{flex:none}
|
||||||
|
.st circle{fill:currentColor}
|
||||||
|
.s-running{color:var(--green)}.s-degraded{color:var(--orange)}.s-stopped{color:var(--dim)}.s-unknown{color:var(--dim)}
|
||||||
|
.s-stopped circle,.s-unknown circle{fill:none}
|
||||||
|
.flags{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.tag{font:500 12px/20px var(--sans);padding:0 8px;border-radius:999px;white-space:nowrap}
|
||||||
|
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
|
||||||
|
.tag em:empty{display:none}
|
||||||
|
.f-up{color:var(--orange);background:var(--orangebg)}
|
||||||
|
.f-err{color:var(--red);background:var(--redbg)}
|
||||||
|
.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
|
||||||
|
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
|
||||||
|
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
|
||||||
|
.sw{display:inline-flex;align-items:center;gap:8px;padding:0;border:0;background:none;font:inherit;font-size:13px;color:var(--mut);cursor:pointer}
|
||||||
|
.sw i{flex:none;width:30px;height:18px;border-radius:999px;background:var(--line2);position:relative;transition:background .15s}
|
||||||
|
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .15s}
|
||||||
|
.sw[aria-checked=true] i{background:var(--blue)}
|
||||||
|
.sw[aria-checked=true] i::after{transform:translateX(12px);background:var(--onblue)}
|
||||||
|
.sw[aria-checked=true]{color:var(--blue);font-weight:600}
|
||||||
|
.sw:disabled{opacity:.55;cursor:not-allowed}
|
||||||
|
.na{font-size:12.5px;color:var(--dim)}
|
||||||
|
.nomatch{padding:24px 6px;color:var(--dim)}
|
||||||
|
|
||||||
|
/* Agents */
|
||||||
|
.hdr.ruled{padding-bottom:16px;border-bottom:2px solid var(--fg);margin-bottom:0}
|
||||||
|
.agent{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) 130px 90px 110px 120px 84px;grid-template-areas:"id st ver mgr last act";align-items:center;column-gap:18px;padding:16px 6px;border-bottom:1px solid var(--line)}
|
||||||
|
.agent .id{grid-area:id}
|
||||||
|
.agent .id b{display:block;font:700 16px var(--sans)}
|
||||||
|
.agent .id span{font:12.5px var(--mono);color:var(--dim)}
|
||||||
|
.agent .stc{grid-area:st}.agent .ver{grid-area:ver;display:block;font:13px var(--mono);color:var(--mut)}.agent .mgr{grid-area:mgr;font-size:13px;color:var(--mut)}.agent .last{grid-area:last;font-size:13px;color:var(--mut)}.agent .act{grid-area:act;display:flex;justify-content:flex-end}
|
||||||
|
.agent .k{display:block;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:2px}
|
||||||
|
.agent .ver.old{color:var(--orange)}
|
||||||
|
.agent .st{font-size:13.5px;font-weight:600}
|
||||||
|
.agent .st i{width:8px;height:8px;border-radius:50%;background:currentColor}
|
||||||
|
.a-on{color:var(--green)}.a-off{color:var(--red)}.a-never,.a-revoked{color:var(--dim)}
|
||||||
|
.a-off i,.a-never i{background:transparent !important;box-shadow:inset 0 0 0 1.5px currentColor}
|
||||||
|
.pop{position:absolute;right:6px;top:calc(100% - 8px);z-index:5;width:360px;padding:14px 16px;border-radius:8px;background:var(--card);border:1px solid var(--line2);box-shadow:var(--shadow);font-size:13px;color:var(--mut)}
|
||||||
|
.pop::before{content:"";position:absolute;top:-6px;right:30px;width:10px;height:10px;background:var(--card);border-left:1px solid var(--line2);border-top:1px solid var(--line2);transform:rotate(45deg)}
|
||||||
|
.pop h4{margin:0 0 4px;font:700 14px var(--sans);color:var(--fg)}
|
||||||
|
.pop p{margin:0 0 12px}
|
||||||
|
.pop .acts{display:flex;gap:8px;justify-content:flex-end;margin:0}
|
||||||
|
.drawer{flex:1 1 440px;max-width:520px;border-left:1px solid var(--line2);background:var(--card);padding:28px 28px 40px;display:flex;flex-direction:column;gap:18px}
|
||||||
|
.dh{display:flex;align-items:center;gap:12px}
|
||||||
|
.dh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
||||||
|
.dh .x{margin-left:auto}
|
||||||
|
.steps{display:flex;gap:8px;font-size:12.5px;color:var(--dim)}
|
||||||
|
.steps span{display:flex;align-items:center;gap:6px}
|
||||||
|
.steps b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;font:600 11px var(--mono);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.steps .done{color:var(--green)}.steps .done b{box-shadow:none;background:var(--green);color:var(--bg)}
|
||||||
|
.steps .now{color:var(--fg)}.steps .now b{box-shadow:none;background:var(--blue);color:var(--onblue)}
|
||||||
|
.steps .sep{width:16px;height:1px;background:var(--line2)}
|
||||||
|
.dform{display:flex;flex-direction:column;gap:18px;margin:0}
|
||||||
|
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;color:var(--mut)}
|
||||||
|
.field input{height:36px;padding:0 12px;border-radius:6px;border:1px solid var(--line2);background:var(--bg);color:var(--fg);font:500 14px var(--mono)}
|
||||||
|
.field small{color:var(--dim);font-size:12.5px}
|
||||||
|
.namebox{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:6px;background:var(--bg);font-size:13.5px}
|
||||||
|
.namebox code{font:500 14px var(--mono);color:var(--fg)}
|
||||||
|
.notice{display:flex;gap:10px;padding:12px 14px;border-radius:6px;background:var(--orangebg);color:var(--fg);font-size:13.5px}
|
||||||
|
.notice svg{flex:none;color:var(--orange);margin-top:1px}
|
||||||
|
.code{border-radius:6px;background:var(--term);overflow:hidden}
|
||||||
|
.code-h{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 12px;border-bottom:1px solid #1d2230;font:12px var(--mono);color:#8d96a8}
|
||||||
|
.code pre{margin:0;padding:12px;border:0;color:#cdd3df;font:12px/1.6 var(--mono);overflow-x:auto}
|
||||||
|
.secret{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px 12px;font-size:13px;color:var(--mut)}
|
||||||
|
.secret code{font:12.5px var(--mono);color:var(--fg);overflow-wrap:anywhere}
|
||||||
|
.df{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:auto}
|
||||||
|
.df form{margin:0}
|
||||||
|
.df > span{margin-right:auto;font-size:13px;color:var(--mut);display:flex;align-items:center;gap:8px}
|
||||||
|
.df .ok{color:var(--green)}
|
||||||
|
.spin{width:12px;height:12px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line2);border-top:2px solid var(--blue);box-sizing:border-box;animation:spin 1s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
|
||||||
|
/* Pages that keep the plain markup: service, jobs, job */
|
||||||
|
h1{font:700 34px/1.1 var(--sans);letter-spacing:-.025em;margin:0 0 18px}
|
||||||
|
h2{font:700 20px var(--sans);letter-spacing:-.015em;margin:28px 0 8px}
|
||||||
|
table{width:100%;border-collapse:collapse;margin-bottom:24px}
|
||||||
|
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
|
||||||
|
th{color:var(--mut);font-weight:500}
|
||||||
|
code,pre{font:13px var(--mono)}
|
||||||
|
pre{padding:12px;border:1px solid var(--line);border-radius:6px;overflow-x:auto}
|
||||||
|
.badge{font-size:12px;color:var(--mut);border:1px solid var(--line);padding:0 6px;border-radius:8px}
|
||||||
|
.status-running{color:var(--green)}
|
||||||
|
.status-degraded,.status-unknown{color:var(--orange)}
|
||||||
|
.status-stopped{color:var(--red)}
|
||||||
|
.error{color:var(--red)}
|
||||||
|
.warning{color:var(--orange)}
|
||||||
|
.muted{color:var(--dim)}
|
||||||
|
.status-job-succeeded{color:var(--green)}
|
||||||
|
.status-job-queued,.status-job-running{color:var(--orange)}
|
||||||
|
.status-job-failed{color:var(--red)}
|
||||||
|
table.details{width:auto}
|
||||||
|
table.details th{padding-right:24px}
|
||||||
|
pre.log{white-space:pre-wrap}
|
||||||
|
|
||||||
|
@media (max-width:760px){
|
||||||
|
.side{flex:1 1 100%;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}
|
||||||
|
#snav{order:3;width:100%}
|
||||||
|
.snav{flex-direction:row;overflow-x:auto}
|
||||||
|
.snav a{padding:10px 12px}
|
||||||
|
.snav .n{margin-left:4px}
|
||||||
|
.mblock{display:none}
|
||||||
|
.livebox{margin:0 0 0 auto;border:0;padding:0}
|
||||||
|
.livebox #live-age{display:none}
|
||||||
|
.main{padding:20px 16px 40px}
|
||||||
|
.hdr h1{font-size:28px}
|
||||||
|
.hdr .end,.tools .end{margin-left:0}
|
||||||
|
.search{flex:1 1 100%}
|
||||||
|
.btn,button:not([class]){height:44px}
|
||||||
|
.btn.sm{height:36px}
|
||||||
|
.row{grid-template-columns:1fr auto;grid-template-areas:"nm st" "ver ver" "fl fl" "mode mode";row-gap:8px;padding:12px 4px}
|
||||||
|
.nm{grid-area:nm}.stc{grid-area:st}.row .ver{grid-area:ver}.flags{grid-area:fl}.mode{grid-area:mode}
|
||||||
|
.flags:empty{display:none}
|
||||||
|
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
|
||||||
|
.drawer{order:-1;max-width:none;border-left:0;border-bottom:1px solid var(--line2);padding:20px 16px}
|
||||||
|
.pop{left:0;right:0;width:auto}
|
||||||
|
table{display:block;overflow-x:auto}
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
|
|
||||||
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
||||||
nav form { margin-left: auto; }
|
|
||||||
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
|
||||||
a { color: var(--accent); }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
|
|
||||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
|
||||||
th { color: var(--muted); font-weight: 500; }
|
|
||||||
code, pre { font: 13px ui-monospace, monospace; }
|
|
||||||
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
|
|
||||||
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
|
|
||||||
.status-running { color: var(--ok); }
|
|
||||||
.status-degraded, .status-unknown { color: var(--warn); }
|
|
||||||
.status-stopped { color: var(--bad); }
|
|
||||||
.error { color: var(--bad); }
|
|
||||||
.warning { color: var(--warn); }
|
|
||||||
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
||||||
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.status-job-succeeded { color: var(--ok); }
|
|
||||||
.status-job-queued, .status-job-running { color: var(--warn); }
|
|
||||||
.status-job-failed { color: var(--bad); }
|
|
||||||
table.details { width: auto; }
|
|
||||||
table.details th { padding-right: 24px; }
|
|
||||||
pre.log { white-space: pre-wrap; }
|
|
||||||
.badge-update { color: var(--warn); border-color: var(--warn); }
|
|
||||||
.badge-error { color: var(--bad); border-color: var(--bad); }
|
|
||||||
form.mode { display: inline; margin-left: 8px; }
|
|
||||||
form.mode button { font-size: 12px; padding: 0 6px; }
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Behaviour the server-rendered pages need in the browser: theme, display
|
||||||
|
// mode, search and machine filter, the Live box, local times, copy buttons and
|
||||||
|
// the revoke popovers. The pages work without it; htmx and the SSE extension
|
||||||
|
// do the live updates. Everything stored in the browser is wrapped in
|
||||||
|
// try/catch because storage can be blocked.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var root = document.documentElement;
|
||||||
|
|
||||||
|
function load(area, key) {
|
||||||
|
try { return window[area].getItem(key); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function save(area, key, value) {
|
||||||
|
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
|
||||||
|
}
|
||||||
|
function each(selector, fn) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme: the head script set the initial class; the toggle overrides it.
|
||||||
|
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
|
||||||
|
var themeButton = document.getElementById('theme-toggle');
|
||||||
|
if (themeButton) {
|
||||||
|
themeButton.addEventListener('click', function () {
|
||||||
|
var light = !root.classList.contains('t-light');
|
||||||
|
root.classList.toggle('t-light', light);
|
||||||
|
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (colorScheme && colorScheme.addEventListener) {
|
||||||
|
colorScheme.addEventListener('change', function (e) {
|
||||||
|
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display mode: icon, icon + text, or text.
|
||||||
|
function setDisplay(mode) {
|
||||||
|
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
|
||||||
|
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
|
||||||
|
}
|
||||||
|
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
|
||||||
|
|
||||||
|
// Search and machine filter. The rows are all in the page; this hides them.
|
||||||
|
var filter = { q: '', machine: '' };
|
||||||
|
try {
|
||||||
|
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
|
||||||
|
if (typeof saved.q === 'string') filter.q = saved.q;
|
||||||
|
if (typeof saved.machine === 'string') filter.machine = saved.machine;
|
||||||
|
} catch (e) { /* ignore a broken saved filter */ }
|
||||||
|
var search = document.getElementById('q');
|
||||||
|
if (search) search.value = filter.q;
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var box = document.getElementById('services');
|
||||||
|
if (!box) return;
|
||||||
|
var groups = box.querySelectorAll('.grp');
|
||||||
|
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
|
||||||
|
if (filter.machine && !known) filter.machine = '';
|
||||||
|
var q = filter.q.trim().toLowerCase();
|
||||||
|
var any = false;
|
||||||
|
Array.prototype.forEach.call(groups, function (g) {
|
||||||
|
var inMachine = !filter.machine || g.dataset.key === filter.machine;
|
||||||
|
var shown = 0;
|
||||||
|
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
|
||||||
|
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
|
||||||
|
row.hidden = !match;
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
g.hidden = shown === 0;
|
||||||
|
if (shown > 0) any = true;
|
||||||
|
});
|
||||||
|
var none = document.getElementById('nomatch');
|
||||||
|
if (none) none.hidden = any || groups.length === 0;
|
||||||
|
each('#machines a', function (a) { a.classList.toggle('cur', a.dataset.machine === filter.machine); });
|
||||||
|
}
|
||||||
|
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live box: counts any event on the stream, including the server's ping.
|
||||||
|
var live = document.getElementById('live');
|
||||||
|
var liveAge = document.getElementById('live-age');
|
||||||
|
var lastEvent = null;
|
||||||
|
var down = false;
|
||||||
|
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
|
||||||
|
function touch() { lastEvent = Date.now(); }
|
||||||
|
document.addEventListener('htmx:sseOpen', function (e) {
|
||||||
|
var source = e.detail && e.detail.source;
|
||||||
|
touch();
|
||||||
|
down = false;
|
||||||
|
if (source && wired && !wired.has(source)) {
|
||||||
|
wired.add(source);
|
||||||
|
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:sseError', function () { down = true; });
|
||||||
|
function renderLive() {
|
||||||
|
if (!live || !liveAge) return;
|
||||||
|
live.classList.toggle('down', down);
|
||||||
|
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
|
||||||
|
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
|
||||||
|
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
|
||||||
|
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Times: the server writes <time datetime="..."> with its own clock as
|
||||||
|
// fallback text; show them in the browser's time zone.
|
||||||
|
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||||
|
function localiseTimes() {
|
||||||
|
each('time[datetime]', function (t) {
|
||||||
|
var d = new Date(t.getAttribute('datetime'));
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
var text;
|
||||||
|
if (t.hasAttribute('data-rel')) {
|
||||||
|
var min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||||
|
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
|
||||||
|
t.title = d.toLocaleString();
|
||||||
|
} else {
|
||||||
|
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
|
||||||
|
}
|
||||||
|
if (t.textContent !== text) t.textContent = text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
|
||||||
|
function flash(button, text) {
|
||||||
|
if (!button.dataset.label) button.dataset.label = button.textContent;
|
||||||
|
button.textContent = text;
|
||||||
|
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
|
||||||
|
}
|
||||||
|
function copyFallback(button, text) {
|
||||||
|
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
|
||||||
|
if (target) {
|
||||||
|
var range = document.createRange();
|
||||||
|
range.selectNodeContents(target);
|
||||||
|
var selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
flash(button, 'Press Ctrl+C');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var area = document.createElement('textarea');
|
||||||
|
area.value = text;
|
||||||
|
area.setAttribute('readonly', '');
|
||||||
|
area.style.position = 'fixed';
|
||||||
|
area.style.opacity = '0';
|
||||||
|
document.body.appendChild(area);
|
||||||
|
area.select();
|
||||||
|
var ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
|
||||||
|
document.body.removeChild(area);
|
||||||
|
flash(button, ok ? 'Copied' : 'Copy failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
|
||||||
|
var openRevokes = [];
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var display = e.target.closest('[data-display]');
|
||||||
|
if (display) {
|
||||||
|
setDisplay(display.dataset.display);
|
||||||
|
save('localStorage', 'pu-display', display.dataset.display);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var machine = e.target.closest('#machines a');
|
||||||
|
if (machine) {
|
||||||
|
e.preventDefault();
|
||||||
|
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
|
||||||
|
saveFilter();
|
||||||
|
applyFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var close = e.target.closest('[data-close]');
|
||||||
|
if (close) {
|
||||||
|
var details = close.closest('details');
|
||||||
|
if (details) details.open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var copy = e.target.closest('[data-copy], [data-copy-target]');
|
||||||
|
if (copy) {
|
||||||
|
var text = copy.dataset.copy;
|
||||||
|
if (text === undefined) {
|
||||||
|
var el = document.getElementById(copy.dataset.copyTarget);
|
||||||
|
text = el ? el.textContent : '';
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
|
||||||
|
} else {
|
||||||
|
copyFallback(copy, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:beforeSwap', function () {
|
||||||
|
openRevokes = [];
|
||||||
|
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:afterSwap', function () {
|
||||||
|
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
|
||||||
|
|
||||||
|
applyFilter();
|
||||||
|
localiseTimes();
|
||||||
|
renderLive();
|
||||||
|
setInterval(renderLive, 1000);
|
||||||
|
setInterval(localiseTimes, 15000);
|
||||||
|
})();
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Agent "{{.Created.Name}}" created</h1>
|
|
||||||
<p class="warning">The token is shown only once. Save this as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</p>
|
|
||||||
<pre>{{.Snippet}}</pre>
|
|
||||||
<p><a href="/agents">Back to agents</a></p>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,38 +1,100 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Agents</h1>
|
<div class="hdr ruled">
|
||||||
<form method="post" action="/agents" class="inline">
|
<h1>Agents</h1>
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
<span class="sub">{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}</span>
|
||||||
<label>Name <input name="name" required placeholder="node-2"></label>
|
<div class="end"><a class="btn pri" href="/agents?add=1">Add agent</a></div>
|
||||||
<button type="submit">Add agent</button>
|
</div>
|
||||||
</form>
|
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
||||||
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
|
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
|
||||||
{{template "agents" .}}
|
{{template "agents" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "agents"}}
|
{{define "agents"}}
|
||||||
<table>
|
{{range .Agents}}
|
||||||
<thead><tr><th>Name</th><th>Hostname</th><th>State</th><th>Version</th><th>Swarm manager</th><th>Last seen</th><th></th></tr></thead>
|
<div class="agent">
|
||||||
<tbody>
|
<div class="id"><b>{{.Name}}</b><span>{{.Hostname}}</span></div>
|
||||||
{{range .Agents}}
|
<div class="stc"><span class="st {{.StatusClass}}"><i></i>{{.Status}}</span></div>
|
||||||
<tr>
|
<div class="ver{{if .VersionOld}} old{{end}}"><span class="k">Agent</span>{{.AgentVersion}}</div>
|
||||||
<td>{{.Name}}</td>
|
<div class="mgr"><span class="k">Swarm</span>{{.Role}}</div>
|
||||||
<td>{{.Hostname}}</td>
|
<div class="last"><span class="k">Last seen</span>{{if .Online}}now{{else}}{{with .LastSeenAt}}<time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}" data-rel>{{.Format "2006-01-02 15:04"}}</time>{{else}}—{{end}}{{end}}</div>
|
||||||
<td>{{if .RevokedAt}}<span class="status status-stopped">revoked</span>{{else if .Online}}<span class="status status-running">online</span>{{else}}<span class="status status-degraded">offline</span>{{end}}</td>
|
<div class="act">{{if not .RevokedAt}}
|
||||||
<td>{{.AgentVersion}}</td>
|
<details class="rv" data-id="{{.ID}}">
|
||||||
<td>{{if .SwarmManager}}yes{{end}}</td>
|
<summary class="btn sm red">Revoke</summary>
|
||||||
<td>{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}}</td>
|
<div class="pop" role="dialog" aria-label="Confirm revoke">
|
||||||
<td>{{if not .RevokedAt}}
|
<h4>Revoke {{.Name}}?</h4>
|
||||||
<form method="post" action="/agents/{{.ID}}/revoke" onsubmit="return confirm('Revoke {{.Name}}? It is disconnected immediately and must be added again to come back.')">
|
<p>Its token stops working immediately. Services on {{.Name}} keep running, but drop out of package-updater.</p>
|
||||||
|
<form method="post" action="/agents/{{.ID}}/revoke" class="acts">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
<button type="submit">Revoke</button>
|
<button type="button" class="btn sm quiet" data-close>Cancel</button>
|
||||||
|
<button type="submit" class="btn sm redfill">Revoke</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}</td>
|
</div>
|
||||||
</tr>
|
</details>
|
||||||
{{else}}
|
{{end}}</div>
|
||||||
<tr><td colspan="7">No agents yet.</td></tr>
|
</div>
|
||||||
{{end}}
|
{{else}}
|
||||||
</tbody>
|
<p class="nomatch">No agents yet.</p>
|
||||||
</table>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The "add agent" panel beside the list. Step 1 asks for a name, step 2 shows the one-time token, step 3 waits for the agent to connect. */}}
|
||||||
|
{{define "drawer"}}
|
||||||
|
{{if .AddStep}}
|
||||||
|
<section class="drawer" aria-label="Add agent">
|
||||||
|
<div class="dh">
|
||||||
|
<h2>Add agent</h2>
|
||||||
|
<a class="btn sm quiet x" href="/agents" aria-label="Close">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"></path></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{if eq .AddStep 1}}
|
||||||
|
<div class="steps" id="add-steps">{{template "steps" 1}}</div>
|
||||||
|
<form method="post" action="/agents" class="dform">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<label class="field">Name
|
||||||
|
<input name="name" required autofocus autocomplete="off" placeholder="node-2" value="{{.FormName}}">
|
||||||
|
<small>Lowercase letters, digits and dashes. Use the machine's name.</small>
|
||||||
|
</label>
|
||||||
|
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||||
|
<div class="df">
|
||||||
|
<a class="btn quiet" href="/agents">Cancel</a>
|
||||||
|
<button type="submit" class="btn pri">Create agent</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="steps" id="add-steps">{{template "steps" 2}}</div>
|
||||||
|
<div class="namebox"><span>Name</span><code>{{.Created.Name}}</code></div>
|
||||||
|
<div class="notice" role="note">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 1.8L15 14H1L8 1.8z"></path><path d="M8 6.5v3.5M8 12v.01"></path></svg>
|
||||||
|
<span><b>This token is shown only once.</b> Save the snippet as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</span>
|
||||||
|
</div>
|
||||||
|
<div class="code">
|
||||||
|
<div class="code-h"><span>docker-compose.yml</span><button type="button" class="btn sm" data-copy-target="snippet">Copy</button></div>
|
||||||
|
<pre id="snippet">{{.Snippet}}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="secret">
|
||||||
|
<span>Token</span><code>{{middle .Token}}</code><button type="button" class="btn sm" data-copy="{{.Token}}">Copy</button>
|
||||||
|
<span>Master key</span><code>{{middle .MasterKey}}</code><button type="button" class="btn sm" data-copy="{{.MasterKey}}">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="df" id="connect" hx-get="/fragments/agents/{{.Created.ID}}/connect" hx-trigger="load, sse:agents" hx-swap="innerHTML">
|
||||||
|
{{template "connect" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* step is the one in progress (1 to 3); 4 marks all three as done. */}}
|
||||||
|
{{define "steps"}}<span class="{{if gt . 1}}done{{else}}now{{end}}"><b>1</b>Name</span><span class="sep"></span><span class="{{if gt . 2}}done{{else if eq . 2}}now{{end}}"><b>2</b>Deploy</span><span class="sep"></span><span class="{{if gt . 3}}done{{else if eq . 3}}now{{end}}"><b>3</b>Connect</span>{{end}}
|
||||||
|
|
||||||
|
{{/* Step 3. Until the agent has connected, Cancel revokes the token that was just created. */}}
|
||||||
|
{{define "connect"}}{{if .Connected}}<span class="ok">Connected · {{.Created.Hostname}} · {{.Created.AgentVersion}}</span>
|
||||||
|
<a class="btn pri" href="/agents">Done</a>
|
||||||
|
<div id="add-steps" hx-swap-oob="innerHTML">{{template "steps" 4}}</div>
|
||||||
|
{{else}}<span><span class="spin" aria-hidden="true"></span>Waiting for {{.Created.Name}}…</span>
|
||||||
|
<form method="post" action="/agents/{{.Created.ID}}/revoke">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<button type="submit" class="btn quiet">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn pri" href="/agents">Done</a>
|
||||||
|
{{end}}{{end}}
|
||||||
|
|||||||
@@ -1,23 +1,62 @@
|
|||||||
{{define "layout"}}<!doctype html>
|
{{define "layout"}}<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="app">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>{{.Title}} · package-updater</title>
|
<title>{{.Title}} · package-updater</title>
|
||||||
|
<script>
|
||||||
|
// Sets the theme and the display mode before first paint. app.js changes them later.
|
||||||
|
(function () {
|
||||||
|
var root = document.documentElement, theme = null, display = null;
|
||||||
|
try { theme = localStorage.getItem('pu-theme'); display = localStorage.getItem('pu-display'); } catch (e) {}
|
||||||
|
if (theme !== 'light' && theme !== 'dark') {
|
||||||
|
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
if (theme === 'light') root.classList.add('t-light');
|
||||||
|
root.classList.add(display === 'icon' || display === 'text' ? 'd-' + display : 'd-both');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="preload" href="/static/schibsted-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/htmx-ext-sse.js"></script>
|
<script src="/static/htmx-ext-sse.js"></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
||||||
<nav>
|
<div class="shell">
|
||||||
<a href="/">Services</a>
|
<aside class="side">
|
||||||
<a href="/jobs">Jobs</a>
|
<div class="brand">
|
||||||
<a href="/agents">Agents</a>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M3 6h14M3 10h9M3 14h6"></path><path d="M15 16V10m-2.5 2.5L15 10l2.5 2.5"></path></svg>
|
||||||
<form method="post" action="/auth/logout">
|
package-updater
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
</div>
|
||||||
<button type="submit">Log out</button>
|
<div id="snav" hx-get="/fragments/nav?section={{.Nav.Section}}" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">{{template "nav" .}}</div>
|
||||||
</form>
|
{{block "machines" .}}{{end}}
|
||||||
</nav>
|
<div class="livebox" id="live">
|
||||||
<main>{{template "content" .}}</main>
|
<b><span class="pulse" aria-hidden="true"></span>Live</b>
|
||||||
|
<span id="live-age">connecting…</span>
|
||||||
|
</div>
|
||||||
|
<div class="sfoot">
|
||||||
|
<button type="button" class="btn sm quiet" id="theme-toggle">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="5"></circle><path d="M7 2v10" ></path></svg>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<button type="submit" class="btn sm quiet">Log out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="work">
|
||||||
|
<main class="main">{{template "content" .}}</main>
|
||||||
|
{{block "drawer" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|
||||||
|
{{define "nav"}}<nav class="snav" aria-label="Main">
|
||||||
|
<a href="/"{{if eq .Nav.Section "services"}} class="cur" aria-current="page"{{end}}>Services <span class="n">{{.Nav.Services}}</span></a>
|
||||||
|
<a href="/jobs"{{if eq .Nav.Section "jobs"}} class="cur" aria-current="page"{{end}}>Jobs</a>
|
||||||
|
<a href="/agents"{{if eq .Nav.Section "agents"}} class="cur" aria-current="page"{{end}}>Agents <span class="n">{{.Nav.AgentsOnline}}/{{.Nav.AgentsTotal}}</span></a>
|
||||||
|
</nav>{{end}}
|
||||||
|
|||||||
@@ -1,38 +1,103 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Services</h1>
|
<div class="hdr">
|
||||||
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services" hx-swap="innerHTML">
|
<h1>Services</h1>
|
||||||
|
</div>
|
||||||
|
<div class="tools">
|
||||||
|
<label class="search">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5"></circle><path d="M10.3 10.3L14 14"></path></svg>
|
||||||
|
<input type="search" id="q" placeholder="Search services" aria-label="Search services" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<div class="end">
|
||||||
|
<span class="lbl" id="showlbl">Show</span>
|
||||||
|
<div class="seg" role="group" aria-labelledby="showlbl">
|
||||||
|
<button type="button" data-display="icon" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="2.5"></rect></svg>
|
||||||
|
Icon
|
||||||
|
</button>
|
||||||
|
<button type="button" data-display="both" aria-pressed="true">
|
||||||
|
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1" y="3" width="8" height="8" rx="2"></rect><path d="M12 7h5"></path></svg>
|
||||||
|
Icon + text
|
||||||
|
</button>
|
||||||
|
<button type="button" data-display="text" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M2 4.5h10M2 9.5h7"></path></svg>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">
|
||||||
{{template "services" .}}
|
{{template "services" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "services"}}
|
{{/* The machine filter in the sidebar. The page defines it; the services fragment replaces its links out of band. */}}
|
||||||
|
{{define "machines"}}
|
||||||
|
<div class="mblock">
|
||||||
|
<div class="slabel">Machines</div>
|
||||||
|
<nav class="mlist" id="machines" aria-label="Filter by machine">{{template "machine-links" .}}</nav>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "machine-links"}}
|
||||||
|
<a class="cur" href="/" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
|
||||||
{{range .Groups}}
|
{{range .Groups}}
|
||||||
<section>
|
<a href="/"{{if .Sub}} class="sub"{{end}} data-machine="{{.Key}}">{{if .Cluster}}<span class="sw-icon" aria-hidden="true"></span>{{else}}<span class="mdot{{if .Offline}} off{{end}}" aria-hidden="true"></span>{{end}}{{.Title}} <span class="n">{{len .Services}}</span></a>
|
||||||
<h2>{{.Title}}</h2>
|
{{end}}
|
||||||
<table>
|
{{end}}
|
||||||
<thead><tr><th>Service</th><th>Image</th><th>Status</th><th>Mode</th></tr></thead>
|
|
||||||
<tbody>
|
{{/* What /fragments/services returns: the groups, and the machine filter swapped in out of band. */}}
|
||||||
{{range .Services}}
|
{{define "services-fragment"}}
|
||||||
<tr>
|
{{template "services" .}}
|
||||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}{{if followsNoTag .Service}} <span class="badge">follows no tag</span>{{end}}{{if .LastPollError}} <span class="badge badge-error" title="{{.LastPollError}}">poll failed</span>{{end}}</td>
|
<div id="machines" hx-swap-oob="innerHTML">{{template "machine-links" .}}</div>
|
||||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
{{end}}
|
||||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
|
||||||
<td>{{.UpdateMode}}
|
{{define "services"}}
|
||||||
<form method="post" action="/services/{{.ID}}/mode" class="mode">
|
{{if .Sprite}}<svg class="sprite" aria-hidden="true">{{range .Sprite}}<symbol id="si-{{.Slug}}" viewBox="0 0 24 24"><path fill="currentColor" d="{{.Path}}"/></symbol>{{end}}</svg>{{end}}
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
{{range .Groups}}
|
||||||
<input type="hidden" name="return" value="overview">
|
<section class="grp{{if .Offline}} offline{{end}}" data-key="{{.Key}}" aria-label="{{.Title}}">
|
||||||
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
<div class="gh">
|
||||||
<button type="submit">Make manual</button>
|
<h2{{if .TitleHint}} title="{{.TitleHint}}"{{end}}>{{.Title}}</h2>
|
||||||
{{else}}<input type="hidden" name="mode" value="auto">
|
<span class="kind">{{.Kind}}</span>
|
||||||
<button type="submit"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>{{if .AutoBlocked}} <span class="muted">{{.AutoBlocked}}</span>{{end}}{{end}}
|
{{if .Meta}}<span class="meta">{{.Meta}}</span>{{end}}
|
||||||
</form>
|
{{if .Offline}}<span class="warn">{{.OfflineLabel}}{{with .OfflineSince}} since <time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}">{{.Format "15:04"}}</time>{{end}} · showing last known state</span>{{end}}
|
||||||
</td>
|
<span class="count">{{len .Services}}</span>
|
||||||
</tr>
|
</div>
|
||||||
{{end}}
|
{{range .Services}}
|
||||||
</tbody>
|
<div class="row" data-search="{{.Search}}">
|
||||||
</table>
|
<div class="nm">
|
||||||
|
<a href="/services/{{.ID}}" title="{{.DisplayName}}" aria-label="{{.DisplayName}}">
|
||||||
|
{{if .Icon}}<span class="ico" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24"><use href="#si-{{.Icon}}"></use></svg></span>{{else}}<span class="ltr {{.LetterClass}}" aria-hidden="true">{{.Letter}}</span>{{end}}
|
||||||
|
<span class="nmt">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
{{if .Stack}}<span class="stk">{{.Stack}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="ver" title="{{.ActualRef}}"><span class="r">{{.Repo}}</span><span class="t"><b>{{.Tag}}</b>{{.Digest}}</span></div>
|
||||||
|
<div class="stc">
|
||||||
|
<span class="st {{.StatusClass}}"><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" stroke="currentColor" stroke-width="1.5"></circle></svg>{{.StatusLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flags">
|
||||||
|
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
|
||||||
|
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
|
||||||
|
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
|
||||||
|
{{if .Self}}<span class="tag f-out">Managed outside</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="mode">
|
||||||
|
{{if .Self}}<span class="na">n/a</span>
|
||||||
|
{{else}}<form method="post" action="/services/{{.ID}}/mode">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
|
<input type="hidden" name="return" value="overview">
|
||||||
|
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
<button type="submit" class="sw" role="switch" aria-checked="true"><i></i>Auto</button>
|
||||||
|
{{else}}<input type="hidden" name="mode" value="auto">
|
||||||
|
<button type="submit" class="sw" role="switch" aria-checked="false"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}><i></i>Manual</button>
|
||||||
|
{{if .AutoBlocked}}<span class="na">{{.AutoBlocked}}</span>{{end}}{{end}}
|
||||||
|
</form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>No services reported yet. <a href="/agents">Add an agent</a> to get started.</p>
|
<p>No services reported yet. <a href="/agents?add=1">Add an agent</a> to get started.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<p class="nomatch" id="nomatch" hidden>No services match.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
|
|||||||
|
|
||||||
for _, path := range []string{"/", "/fragments/services"} {
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
_, body := get(t, srv, path)
|
_, body := get(t, srv, path)
|
||||||
assert.Equal(t, 1, strings.Count(body, "follows no tag"), "only the digest-only Swarm service: "+path)
|
assert.Equal(t, 1, strings.Count(body, "Follows no tag"), "only the digest-only Swarm service: "+path)
|
||||||
assert.Contains(t, body, `class="badge badge-error" title="head x: "boom" <script>">poll failed</span>`, path)
|
assert.Contains(t, body, `class="tag f-err" title="head x: "boom" <script>">Poll failed</span>`, path)
|
||||||
assert.Equal(t, 1, strings.Count(body, "poll failed"), path)
|
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+245
-25
@@ -1,58 +1,239 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/shcizo/package-updater/internal/imageref"
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
"github.com/shcizo/package-updater/internal/master/poller"
|
"github.com/shcizo/package-updater/internal/master/poller"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/web/icons"
|
||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"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 {
|
type serviceGroup struct {
|
||||||
Title string
|
Key string // "agent-<id>", "swarm-<cluster id>" or "unknown"; the machine filter matches on it
|
||||||
Services []serviceRow
|
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 {
|
type serviceRow struct {
|
||||||
model.Service
|
model.Service
|
||||||
AutoBlocked string // why the service cannot be set to auto; "" when it can
|
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
|
// newServiceRow fills the display fields of a row.
|
||||||
// in one group, keeping the store's order (machines first, Swarm last).
|
func newServiceRow(s model.Service, autoBlocked string) serviceRow {
|
||||||
// autoBlocked is called for every service and returns the reason it cannot be
|
r := serviceRow{Service: s, AutoBlocked: autoBlocked}
|
||||||
// set to auto, or "" when it can.
|
switch s.Kind {
|
||||||
func groupServices(services []model.Service, autoBlocked func(model.Service) string) []serviceGroup {
|
case protocol.KindCompose:
|
||||||
var groups []serviceGroup
|
r.Name, r.Stack = s.ComposeService, s.ComposeProject
|
||||||
index := map[string]int{}
|
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 {
|
for _, s := range services {
|
||||||
title := s.AgentName
|
row := newServiceRow(s, autoBlocked(s))
|
||||||
if s.Kind == protocol.KindSwarm {
|
_, known := agentByID[derefID(s.AgentID)]
|
||||||
title = "Swarm"
|
switch {
|
||||||
} else if title == "" {
|
case s.Kind == protocol.KindSwarm:
|
||||||
title = "Unknown agent"
|
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)
|
machineIDs := make([]int64, 0, len(machineRows))
|
||||||
index[title] = i
|
for id := range machineRows {
|
||||||
groups = append(groups, serviceGroup{Title: title})
|
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
|
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.
|
// shortRef shortens a digest in an image reference to 12 hex characters.
|
||||||
func shortRef(ref string) string {
|
func shortRef(ref string) string {
|
||||||
r := imageref.Parse(ref)
|
r := imageref.Parse(ref)
|
||||||
if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 {
|
r.Digest = shortDigest(r.Digest)
|
||||||
r.Digest = "sha256:" + hex[:12] + "…"
|
|
||||||
}
|
|
||||||
return r.String()
|
return r.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +282,48 @@ func shortDuration(d time.Duration) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentRow is an agent as shown on the agents page.
|
||||||
type agentRow struct {
|
type agentRow struct {
|
||||||
model.Agent
|
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.
|
// jobDuration is how long a job ran, or has been running; "" before it starts.
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImageParts(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ref, repo, tag, digest string
|
||||||
|
}{
|
||||||
|
{"postgres:17.10", "postgres", ":17.10", ""},
|
||||||
|
{"postgres", "postgres", "", ""},
|
||||||
|
{"gitea/gitea:1.27.3", "gitea/gitea", ":1.27.3", ""},
|
||||||
|
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", "shcizo/slusk", ":v0.8.1", ""},
|
||||||
|
{"localhost:5000/foo:v1", "foo", ":v1", ""},
|
||||||
|
{"localhost/foo", "foo", "", ""},
|
||||||
|
{"lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", "linuxserver/jellyfin", "", "@sha256:0f42497a69fa…"},
|
||||||
|
{"r/x:1.0@sha256:0f42497a69fa0441", "r/x", ":1.0", ""}, // the digest is hidden when there is a tag
|
||||||
|
{"r/x@sha256:abc", "r/x", "", "@sha256:abc"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.ref, func(t *testing.T) {
|
||||||
|
repo, tag, digest := imageParts(c.ref)
|
||||||
|
assert.Equal(t, c.repo, repo)
|
||||||
|
assert.Equal(t, c.tag, tag)
|
||||||
|
assert.Equal(t, c.digest, digest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLetterClass(t *testing.T) {
|
||||||
|
assert.Equal(t, "lc-0", letterClass(""))
|
||||||
|
assert.Equal(t, "lc-1", letterClass("a"), "97 mod 4")
|
||||||
|
assert.Equal(t, letterClass("gitea"), letterClass("gitea"), "a name always gets the same colour")
|
||||||
|
for _, name := range []string{"gitea", "authentik-server", "ø", "n8n"} {
|
||||||
|
assert.Regexp(t, `^lc-[0-3]$`, letterClass(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentRow(t *testing.T) {
|
||||||
|
seen := ptr(time.Now())
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
agent model.Agent
|
||||||
|
online bool
|
||||||
|
masterVersion string
|
||||||
|
status, role string
|
||||||
|
old bool
|
||||||
|
}{
|
||||||
|
{"online manager", model.Agent{SwarmManager: true, AgentVersion: "v1"}, true, "v1", "Online", "Manager", false},
|
||||||
|
{"offline worker on another version", model.Agent{SwarmClusterID: "c", AgentVersion: "v0", LastSeenAt: seen}, false, "v1", "Offline", "Worker", true},
|
||||||
|
{"never connected", model.Agent{}, false, "v1", "Never connected", "—", false},
|
||||||
|
{"revoked wins over online", model.Agent{RevokedAt: seen, LastSeenAt: seen}, true, "v1", "Revoked", "—", false},
|
||||||
|
{"dev master marks nothing", model.Agent{AgentVersion: "v0"}, true, "dev", "Online", "—", false},
|
||||||
|
{"agent without version", model.Agent{}, true, "v1", "Online", "—", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
row := newAgentRow(c.agent, c.online, c.masterVersion)
|
||||||
|
assert.Equal(t, c.status, row.Status)
|
||||||
|
assert.Equal(t, c.role, row.Role)
|
||||||
|
assert.Equal(t, c.old, row.VersionOld)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddle(t *testing.T) {
|
||||||
|
assert.Equal(t, "short", middle("short"))
|
||||||
|
assert.Equal(t, "pua_7Hq2…Ge5A", middle("pua_7Hq2mX9vKc4LwT8nRb3ZyF6dJp1sGe5A"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupServices(t *testing.T) {
|
||||||
|
at := func(h, m int) *time.Time { return ptr(time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)) }
|
||||||
|
id := func(n int64) *int64 { return &n }
|
||||||
|
compose := func(sid int64, agent *int64, name string) model.Service {
|
||||||
|
return model.Service{ID: sid, Kind: protocol.KindCompose, AgentID: agent, ComposeProject: "p", ComposeService: name, ActualRef: "r/" + name + ":1"}
|
||||||
|
}
|
||||||
|
swarm := func(sid int64, cluster string, agent *int64) model.Service {
|
||||||
|
return model.Service{ID: sid, Kind: protocol.KindSwarm, SwarmClusterID: cluster, AgentID: agent, SwarmService: "s" + cluster}
|
||||||
|
}
|
||||||
|
noBlock := func(model.Service) string { return "" }
|
||||||
|
onlineSet := func(ids ...int64) func(int64) bool {
|
||||||
|
return func(a int64) bool {
|
||||||
|
for _, i := range ids {
|
||||||
|
if i == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
titles := func(groups []serviceGroup) []string {
|
||||||
|
var out []string
|
||||||
|
for _, g := range groups {
|
||||||
|
out = append(out, g.Title)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
assert.Empty(t, groupServices(nil, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(), noBlock))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("agents without services get no group", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a"}, {ID: 2, Name: "idle"}}, onlineSet(1, 2), noBlock)
|
||||||
|
assert.Equal(t, []string{"a"}, titles(groups))
|
||||||
|
assert.Equal(t, "agent-1", groups[0].Key)
|
||||||
|
assert.Equal(t, "Machine", groups[0].Kind)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("services without a known agent are offline and last", func(t *testing.T) {
|
||||||
|
groups := groupServices(
|
||||||
|
[]model.Service{compose(1, nil, "orphan"), compose(2, id(7), "ghost"), compose(3, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), noBlock)
|
||||||
|
assert.Equal(t, []string{"a", "Unknown agent"}, titles(groups))
|
||||||
|
unknown := groups[1]
|
||||||
|
assert.Equal(t, "unknown", unknown.Key)
|
||||||
|
assert.True(t, unknown.Offline)
|
||||||
|
assert.Nil(t, unknown.OfflineSince)
|
||||||
|
assert.Len(t, unknown.Services, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an offline agent says since when", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a", Hostname: "a.lan", LastSeenAt: at(13, 51)}}, onlineSet(), noBlock)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
assert.True(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "Agent offline", groups[0].OfflineLabel)
|
||||||
|
assert.Equal(t, at(13, 51), groups[0].OfflineSince)
|
||||||
|
assert.Equal(t, "a.lan", groups[0].Meta)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a cluster is online when any manager is", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(2), noBlock)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "Swarm", groups[0].Title)
|
||||||
|
assert.Equal(t, "c1", groups[0].TitleHint)
|
||||||
|
assert.Equal(t, "Swarm cluster", groups[0].Kind)
|
||||||
|
assert.Equal(t, "managers m1, m2", groups[0].Meta)
|
||||||
|
assert.Equal(t, "swarm-c1", groups[0].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a cluster with no manager online has been offline since the latest manager was seen", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(), noBlock)
|
||||||
|
assert.True(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "No manager online", groups[0].OfflineLabel)
|
||||||
|
assert.Equal(t, at(11, 0), groups[0].OfflineSince)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a revoked manager does not count", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "gone", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(12, 0), RevokedAt: at(12, 5)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(9, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(1), noBlock)
|
||||||
|
assert.True(t, groups[0].Offline, "the revoked manager is not connected for this purpose")
|
||||||
|
assert.Equal(t, "managers m2", groups[0].Meta)
|
||||||
|
assert.Equal(t, at(9, 0), groups[0].OfflineSince)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("managers are found through the rows when no agent reports the cluster", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{{ID: 4, Name: "reporter", LastSeenAt: at(8, 0)}}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", id(4))}, agents, onlineSet(4), noBlock)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "managers reporter", groups[0].Meta)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("order is cluster, its machines, other machines by name, unknown", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "zeta"},
|
||||||
|
{ID: 2, Name: "mgr", SwarmManager: true, SwarmClusterID: "c1"},
|
||||||
|
{ID: 3, Name: "alpha"},
|
||||||
|
{ID: 4, Name: "mgr2", SwarmManager: true, SwarmClusterID: "c1"},
|
||||||
|
}
|
||||||
|
services := []model.Service{
|
||||||
|
compose(1, id(1), "a"), compose(2, id(2), "b"), compose(3, id(3), "c"),
|
||||||
|
compose(4, id(4), "d"), compose(5, nil, "e"), swarm(6, "c1", nil),
|
||||||
|
}
|
||||||
|
groups := groupServices(services, agents, onlineSet(1, 2, 3, 4), noBlock)
|
||||||
|
assert.Equal(t, []string{"Swarm", "mgr", "mgr2", "alpha", "zeta", "Unknown agent"}, titles(groups))
|
||||||
|
assert.Equal(t, []bool{false, true, true, false, false, false}, []bool{groups[0].Sub, groups[1].Sub, groups[2].Sub, groups[3].Sub, groups[4].Sub, groups[5].Sub})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("two clusters carry their ids in the titles", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "abcdefghijkl", nil), swarm(2, "xyz", nil)}, nil, onlineSet(), noBlock)
|
||||||
|
assert.Equal(t, []string{"Swarm abcdefgh", "Swarm xyz"}, titles(groups))
|
||||||
|
assert.Equal(t, "abcdefghijkl", groups[0].TitleHint)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the auto-blocked reason is kept on the row", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1),
|
||||||
|
func(model.Service) string { return "because" })
|
||||||
|
assert.Equal(t, "because", groups[0].Services[0].AutoBlocked)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user