feat(master): htmx UI with live service overview and agent management
Vendored static assets (sha256): e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447 htmx.min.js (htmx.org@2.0.4) 83eca6fa0611fe2b0bf1700b424b88b5eced38ef448ef9760a2ea08fbc875611 htmx-ext-sse.js (htmx-ext-sse@2.2.2)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// fakeStore is written from the HTTP handler goroutine and read by the test
|
||||
// goroutine, so every access goes through mu.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
services []model.Service
|
||||
agents []model.Agent
|
||||
created map[string]string // name → token hash
|
||||
revokedID int64
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
||||
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
|
||||
|
||||
func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, taken := f.created[name]; taken {
|
||||
return model.Agent{}, model.ErrConflict
|
||||
}
|
||||
f.created[name] = hash
|
||||
return model.Agent{ID: 9, Name: name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeAgent(_ context.Context, id int64, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.revokedID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) createdHash(name string) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.created[name]
|
||||
}
|
||||
|
||||
func (f *fakeStore) revoked() int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.revokedID
|
||||
}
|
||||
|
||||
type fakeHub struct {
|
||||
mu sync.Mutex
|
||||
online map[int64]bool
|
||||
disconnected int64
|
||||
}
|
||||
|
||||
func (h *fakeHub) Online(id int64) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.online[id]
|
||||
}
|
||||
|
||||
func (h *fakeHub) Disconnect(id int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.disconnected = id
|
||||
}
|
||||
|
||||
func (h *fakeHub) lastDisconnected() int64 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.disconnected
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) {
|
||||
t.Helper()
|
||||
agentID := int64(2)
|
||||
store := &fakeStore{
|
||||
created: map[string]string{"node-2": "x"},
|
||||
services: []model.Service{
|
||||
{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"},
|
||||
{Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
||||
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
||||
{Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
||||
},
|
||||
agents: []model.Agent{
|
||||
{ID: 2, Name: "node-2", Hostname: "node-2"},
|
||||
{ID: 3, Name: "node-3"},
|
||||
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
||||
},
|
||||
}
|
||||
hub := &fakeHub{online: map[int64]bool{2: true}}
|
||||
broker := events.NewBroker()
|
||||
s, err := New(store, hub, broker, Config{
|
||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
require.NoError(t, err)
|
||||
mux := http.NewServeMux()
|
||||
s.Register(mux, func(h http.Handler) http.Handler { return h })
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, store, hub, broker
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func get(t *testing.T, srv *httptest.Server, path string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
}
|
||||
|
||||
func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
code, body := get(t, srv, "/")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, "<html")
|
||||
assert.Contains(t, body, "node-2")
|
||||
assert.Contains(t, body, "Swarm")
|
||||
assert.Contains(t, body, "slusk/web")
|
||||
assert.Contains(t, body, "jellyfin_jellyfin")
|
||||
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
||||
assert.Contains(t, body, "managed outside")
|
||||
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
|
||||
}
|
||||
|
||||
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
code, body := get(t, srv, "/fragments/services")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.NotContains(t, body, "<html")
|
||||
assert.Contains(t, body, "slusk/web")
|
||||
}
|
||||
|
||||
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
_, body := get(t, srv, "/agents")
|
||||
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(`old[\s\S]*revoked`), body)
|
||||
}
|
||||
|
||||
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
||||
srv, store, _, _ := newTestServer(t)
|
||||
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-4"}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
token := regexp.MustCompile(`pua_[A-Za-z0-9_-]{43}`).FindString(string(body))
|
||||
require.NotEmpty(t, token)
|
||||
assert.Equal(t, agenttoken.Hash(token), store.createdHash("node-4"), "only the hash is stored")
|
||||
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), "shown only once")
|
||||
}
|
||||
|
||||
func TestCreateAgent_Validation(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
cases := []struct {
|
||||
name, input string
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{"duplicate", "node-2", http.StatusConflict, "already exists"},
|
||||
{"invalid characters", "Node 2!", http.StatusBadRequest, "lowercase letters, digits and dashes"},
|
||||
{"empty", " ", http.StatusBadRequest, "lowercase letters, digits and dashes"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {c.input}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, c.wantCode, resp.StatusCode)
|
||||
assert.Contains(t, string(body), c.wantText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
|
||||
srv, store, hub, _ := newTestServer(t)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/agents/2/revoke", "application/x-www-form-urlencoded", nil)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, "/agents", resp.Header.Get("Location"))
|
||||
assert.Equal(t, int64(2), store.revoked())
|
||||
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
||||
}
|
||||
|
||||
func TestRevokeAgent_BadID(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/agents/abc/revoke", "application/x-www-form-urlencoded", nil)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestEvents_StreamsTopics(t *testing.T) {
|
||||
srv, _, _, broker := 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()
|
||||
|
||||
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
|
||||
assert.Equal(t, "no", resp.Header.Get("X-Accel-Buffering"), "nginx must not buffer the stream")
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
line, err := reader.ReadString('\n') // ": connected"
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(line, ":"))
|
||||
|
||||
broker.Publish(events.Services)
|
||||
for {
|
||||
line, err = reader.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "event: services\n", line)
|
||||
}
|
||||
Reference in New Issue
Block a user