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,284 @@
|
||||
// Package web serves the master's htmx UI.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/master/webauth"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Store is the storage the UI reads and writes. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
ListServices(ctx context.Context) ([]model.Service, error)
|
||||
ListAgents(ctx context.Context) ([]model.Agent, error)
|
||||
CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error)
|
||||
RevokeAgent(ctx context.Context, id int64, at time.Time) error
|
||||
}
|
||||
|
||||
// Hub reports and controls live agent connections. *hub.Hub satisfies it.
|
||||
type Hub interface {
|
||||
Online(agentID int64) bool
|
||||
Disconnect(agentID int64)
|
||||
}
|
||||
|
||||
// Subscriber delivers change signals. *events.Broker satisfies it.
|
||||
type Subscriber interface {
|
||||
Subscribe() (<-chan events.Topic, func())
|
||||
}
|
||||
|
||||
// Config holds values shown in the "add agent" snippet.
|
||||
type Config struct {
|
||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||
MasterKey string // the master's fingerprint
|
||||
AgentImage string
|
||||
}
|
||||
|
||||
// Server renders the UI.
|
||||
type Server struct {
|
||||
store Store
|
||||
hub Hub
|
||||
sub Subscriber
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// New parses the templates. Each page is its own template set so every page
|
||||
// can define its own "content" block.
|
||||
func New(store Store, hub Hub, sub Subscriber, cfg Config, log *slog.Logger) (*Server, error) {
|
||||
funcs := template.FuncMap{"shortRef": shortRef}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"overview.html", "agents.html", "agent_created.html"} {
|
||||
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", page, err)
|
||||
}
|
||||
pages[page] = t
|
||||
}
|
||||
return &Server{store: store, hub: hub, sub: sub, cfg: cfg, log: log, now: time.Now, pages: pages}, nil
|
||||
}
|
||||
|
||||
// Register adds the UI routes to mux. protect wraps every route except
|
||||
// static assets, normally with webauth's Require.
|
||||
func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /static/", http.FileServerFS(assets))
|
||||
route := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, protect(h)) }
|
||||
route("GET /{$}", s.overview)
|
||||
route("GET /fragments/services", s.servicesFragment)
|
||||
route("GET /agents", s.agents)
|
||||
route("GET /fragments/agents", s.agentsFragment)
|
||||
route("POST /agents", s.createAgent)
|
||||
route("POST /agents/{id}/revoke", s.revokeAgent)
|
||||
route("GET /events", s.events)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
CSRF string
|
||||
Groups []serviceGroup
|
||||
Agents []agentRow
|
||||
Error string
|
||||
Created model.Agent
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (s *Server) base(r *http.Request, title string) pageData {
|
||||
sess, _ := webauth.SessionFrom(r.Context())
|
||||
return pageData{Title: title, CSRF: sess.CSRFToken}
|
||||
}
|
||||
|
||||
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.WriteHeader(status)
|
||||
if err := s.pages[page].ExecuteTemplate(w, block, data); err != nil {
|
||||
s.log.Error("render_failed", "page", page, "block", block, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serviceData(r *http.Request) (pageData, error) {
|
||||
data := s.base(r, "Services")
|
||||
services, err := s.store.ListServices(r.Context())
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Groups = groupServices(services)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.serviceData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "overview.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.serviceData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "overview.html", "services", data)
|
||||
}
|
||||
|
||||
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
||||
data := s.base(r, "Agents")
|
||||
agents, err := s.store.ListAgents(r.Context())
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
for _, a := range agents {
|
||||
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.agentData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "agents.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.agentData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "agents.html", "agents", data)
|
||||
}
|
||||
|
||||
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if !agentNamePattern.MatchString(name) {
|
||||
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
|
||||
return
|
||||
}
|
||||
token, err := agenttoken.Generate()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
||||
if errors.Is(err, model.ErrConflict) {
|
||||
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
data := s.base(r, "Agent created")
|
||||
data.Created = agent
|
||||
data.Snippet = agentSnippet(s.cfg, 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)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
data.Error = msg
|
||||
s.render(w, status, "agents.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid agent id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAgent(r.Context(), id, s.now()); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.hub.Disconnect(id)
|
||||
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
||||
// 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.
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // stop nginx from buffering the stream
|
||||
|
||||
ch, cancel := s.sub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
if _, err := fmt.Fprint(w, ": connected\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rc.Flush(); err != nil {
|
||||
s.log.Error("sse_flush_unsupported", "error", err)
|
||||
return
|
||||
}
|
||||
keepalive := time.NewTicker(25 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
for {
|
||||
var msg string
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case t := <-ch:
|
||||
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
||||
case <-keepalive.C:
|
||||
msg = ": keepalive\n\n"
|
||||
}
|
||||
if _, err := fmt.Fprint(w, msg); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rc.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, err error) {
|
||||
s.log.Error("request_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// agentSnippet is the compose file shown once when an agent is added.
|
||||
func agentSnippet(cfg Config, token string) string {
|
||||
return fmt.Sprintf(`services:
|
||||
updater-agent:
|
||||
image: %s
|
||||
entrypoint: ["/usr/local/bin/updater-agent"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MASTER_URL: %s
|
||||
MASTER_KEY: %s
|
||||
AGENT_TOKEN: %s
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
`, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token)
|
||||
}
|
||||
Reference in New Issue
Block a user