- icons/gen writes to the current directory so `go generate ./internal/master/web/icons` works (output verified byte-identical) - share the registry-host check as imageref.IsRegistryHost (with test) - agents fragment refreshes the "N machines" header count out of band - ignore the saved machine filter at <=760px where the machine list is hidden - aria-current on the active machine filter link, aria-pressed on theme toggle - CLAUDE.md: icons package, SSE ping event, swarm_cluster_id Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
730 lines
22 KiB
Go
730 lines
22 KiB
Go
// Package web serves the master's htmx UI.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"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/web/icons"
|
|
"github.com/shcizo/package-updater/internal/master/webauth"
|
|
)
|
|
|
|
//go:embed templates/*.html static/*
|
|
var assets embed.FS
|
|
|
|
// serviceJobsLimit is how many jobs the service page lists.
|
|
const serviceJobsLimit = 20
|
|
|
|
// versionsLimit is how many image versions the service page lists.
|
|
const versionsLimit = 10
|
|
|
|
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)
|
|
GetService(ctx context.Context, id int64) (model.Service, error)
|
|
// RecentVersions returns up to limit versions of repo the master has
|
|
// seen, most recently first seen first.
|
|
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
|
|
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
|
|
SetUpdateMode(ctx context.Context, id int64, mode string) error
|
|
GetJob(ctx context.Context, id int64) (model.Job, error)
|
|
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
|
|
ListAgents(ctx context.Context) ([]model.Agent, error)
|
|
CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error)
|
|
// RevokeAgent returns the IDs of the jobs it failed.
|
|
RevokeAgent(ctx context.Context, id int64, at time.Time) ([]int64, error)
|
|
}
|
|
|
|
// Hub reports and controls live agent connections. *hub.Hub satisfies it.
|
|
type Hub interface {
|
|
Online(agentID int64) bool
|
|
Disconnect(agentID int64)
|
|
}
|
|
|
|
// Jobs starts updates and explains why a service cannot be updated or set
|
|
// to auto mode. *jobs.Service satisfies it.
|
|
type Jobs interface {
|
|
Updatable(svc model.Service) error
|
|
AutoAllowed(svc model.Service) error
|
|
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
|
// TriggerVersion updates to exactly the image version versionID of the
|
|
// service's repo; model.ErrNotFound for an unknown service or version.
|
|
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
|
|
}
|
|
|
|
// Broker delivers change signals to open streams and accepts signals from
|
|
// UI actions. *events.Broker satisfies it.
|
|
type Broker interface {
|
|
Subscribe() (<-chan events.Topic, func())
|
|
Publish(t events.Topic)
|
|
}
|
|
|
|
// Config holds values shown in the "add agent" snippet, on the agents page and
|
|
// on the service page.
|
|
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
|
|
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.
|
|
type Server struct {
|
|
store Store
|
|
hub Hub
|
|
jobs Jobs
|
|
broker Broker
|
|
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, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
|
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
|
|
pages := map[string]*template.Template{}
|
|
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)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", page, err)
|
|
}
|
|
pages[page] = t
|
|
}
|
|
return &Server{store: store, hub: hub, jobs: jobs, broker: broker, 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 /services/{id}", s.servicePage)
|
|
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
|
route("POST /services/{id}/update", s.updateService)
|
|
route("POST /services/{id}/mode", s.setMode)
|
|
route("GET /jobs", s.jobsPage)
|
|
route("GET /fragments/jobs", s.jobsFragment)
|
|
route("GET /jobs/{id}", s.jobPage)
|
|
route("GET /fragments/jobs/{id}", s.jobFragment)
|
|
route("GET /agents", s.agents)
|
|
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/{id}/revoke", s.revokeAgent)
|
|
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 {
|
|
Title string
|
|
CSRF string
|
|
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
|
|
Error string
|
|
Created model.Agent
|
|
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
|
|
ConfigFiles []string
|
|
Reason string // why Service cannot be updated; "" when it can
|
|
AutoBlocked string // why Service cannot be set to auto; "" when it can
|
|
Versions []versionRow
|
|
Polling string // what the service page says about registry polling
|
|
PollFailed bool // Polling is the last registry error
|
|
Jobs []model.Job
|
|
Job model.Job
|
|
}
|
|
|
|
func (s *Server) base(r *http.Request, title string) pageData {
|
|
sess, _ := webauth.SessionFrom(r.Context())
|
|
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) {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
data, err := s.serviceData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
s.page(w, r, http.StatusOK, "overview.html", 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-fragment", data)
|
|
}
|
|
|
|
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
|
|
func (s *Server) autoBlocked(svc model.Service) string {
|
|
var nu *model.NotUpdatableError
|
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
|
return nu.Reason
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pathID parses the {id} path value. On failure it has already answered 400.
|
|
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// failLookup answers 404 for model.ErrNotFound and 500 otherwise.
|
|
func (s *Server) failLookup(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
s.fail(w, err)
|
|
}
|
|
|
|
func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
|
|
svc, err := s.store.GetService(r.Context(), id)
|
|
if err != nil {
|
|
return pageData{}, err
|
|
}
|
|
data := s.base(r, svc.DisplayName())
|
|
data.Service = svc
|
|
if svc.ConfigFiles != "" {
|
|
data.ConfigFiles = strings.Split(svc.ConfigFiles, ",")
|
|
}
|
|
var nu *model.NotUpdatableError
|
|
if err := s.jobs.Updatable(svc); errors.As(err, &nu) {
|
|
data.Reason = nu.Reason
|
|
} else if err != nil {
|
|
return data, err
|
|
}
|
|
data.AutoBlocked = s.autoBlocked(svc)
|
|
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
data.Versions = versionRows(versions, svc.ActualDigest)
|
|
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
|
|
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
|
return data, err
|
|
}
|
|
|
|
func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data, err := s.serviceDetail(r, id)
|
|
if err != nil {
|
|
s.failLookup(w, err)
|
|
return
|
|
}
|
|
s.page(w, r, http.StatusOK, "service.html", data)
|
|
}
|
|
|
|
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
jobs, err := s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
data := s.base(r, "Service")
|
|
data.Jobs = jobs
|
|
s.render(w, http.StatusOK, "service.html", "history", data)
|
|
}
|
|
|
|
func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
sess, _ := webauth.SessionFrom(r.Context())
|
|
var job model.Job
|
|
var err error
|
|
if v := r.PostFormValue("version"); v == "" {
|
|
job, err = s.jobs.Trigger(r.Context(), id, sess.Subject)
|
|
} else {
|
|
versionID, perr := strconv.ParseInt(v, 10, 64)
|
|
if perr != nil {
|
|
http.Error(w, "invalid version", http.StatusBadRequest)
|
|
return
|
|
}
|
|
job, err = s.jobs.TriggerVersion(r.Context(), id, versionID, sess.Subject)
|
|
}
|
|
var nu *model.NotUpdatableError
|
|
switch {
|
|
case err == nil || errors.Is(err, model.ErrJobActive):
|
|
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
|
|
case errors.As(err, &nu):
|
|
data, derr := s.serviceDetail(r, id)
|
|
if derr != nil {
|
|
s.failLookup(w, derr)
|
|
return
|
|
}
|
|
data.Error = "Cannot update: " + nu.Reason + "."
|
|
s.page(w, r, http.StatusConflict, "service.html", data)
|
|
default:
|
|
s.failLookup(w, err)
|
|
}
|
|
}
|
|
|
|
// setMode switches a service between manual and auto. Manual is always
|
|
// allowed; auto only when jobs.AutoAllowed says so.
|
|
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
mode := r.PostFormValue("mode")
|
|
if mode != model.ModeManual && mode != model.ModeAuto {
|
|
http.Error(w, "invalid mode", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if mode == model.ModeAuto {
|
|
svc, err := s.store.GetService(r.Context(), id)
|
|
if err == nil && svc.RemovedAt != nil {
|
|
err = model.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
s.failLookup(w, err)
|
|
return
|
|
}
|
|
var nu *model.NotUpdatableError
|
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
|
data, derr := s.serviceDetail(r, id)
|
|
if derr != nil {
|
|
s.failLookup(w, derr)
|
|
return
|
|
}
|
|
data.Error = "Cannot enable auto: " + nu.Reason + "."
|
|
s.page(w, r, http.StatusConflict, "service.html", data)
|
|
return
|
|
} else if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := s.store.SetUpdateMode(r.Context(), id, mode); err != nil {
|
|
s.failLookup(w, err)
|
|
return
|
|
}
|
|
s.broker.Publish(events.Services)
|
|
if r.PostFormValue("return") == "overview" {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) jobsData(r *http.Request) (pageData, error) {
|
|
data := s.base(r, "Jobs")
|
|
var err error
|
|
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{Limit: 100})
|
|
return data, err
|
|
}
|
|
|
|
func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.jobsData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
s.page(w, r, http.StatusOK, "jobs.html", data)
|
|
}
|
|
|
|
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.jobsData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
s.render(w, http.StatusOK, "jobs.html", "jobs", data)
|
|
}
|
|
|
|
// jobData always reads the job from the store, so a fragment fetched after
|
|
// a dropped signal still shows the full current output.
|
|
func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return pageData{}, false
|
|
}
|
|
job, err := s.store.GetJob(r.Context(), id)
|
|
if err != nil {
|
|
s.failLookup(w, err)
|
|
return pageData{}, false
|
|
}
|
|
data := s.base(r, fmt.Sprintf("Job #%d", job.ID))
|
|
data.Job = job
|
|
return data, true
|
|
}
|
|
|
|
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
|
|
if data, ok := s.jobData(w, r); ok {
|
|
s.page(w, r, http.StatusOK, "job.html", data)
|
|
}
|
|
}
|
|
|
|
func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) {
|
|
if data, ok := s.jobData(w, r); ok {
|
|
s.render(w, http.StatusOK, "job.html", "job", data)
|
|
}
|
|
}
|
|
|
|
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
|
data := s.base(r, "Agents")
|
|
data.MasterVersion = s.cfg.Version
|
|
agents, err := s.store.ListAgents(r.Context())
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
for _, a := range agents {
|
|
data.Agents = append(data.Agents, newAgentRow(a, s.hub.Online(a.ID), s.cfg.Version))
|
|
}
|
|
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) {
|
|
data, err := s.agentData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
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) {
|
|
data, err := s.agentData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
if data.Nav, err = s.navData(r.Context(), "agents"); err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
s.render(w, http.StatusOK, "agents.html", "agents-fragment", 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) {
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
if !agentNamePattern.MatchString(name) {
|
|
s.agentsWithError(w, r, http.StatusBadRequest, name, "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, name, fmt.Sprintf("An agent named %q already exists.", name))
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
data, err := s.agentData(r)
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
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
|
|
s.page(w, r, status, "agents.html", 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
|
|
}
|
|
failedJobs, err := s.store.RevokeAgent(r.Context(), id, s.now())
|
|
if err != nil {
|
|
s.fail(w, err)
|
|
return
|
|
}
|
|
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.Jobs) // the agent's active jobs were failed
|
|
for _, jobID := range failedJobs {
|
|
s.broker.Publish(events.JobTopic(jobID)) // open job pages show the failure
|
|
}
|
|
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
|
|
// 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.broker.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(pingInterval)
|
|
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 = "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 {
|
|
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
|
|
UPDATE_TIMEOUT: 5m
|
|
# Optional registry credentials (a config.json written by docker login):
|
|
# DOCKER_CONFIG: /docker-config
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
# - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write
|
|
# - /root/.docker/config.json:/docker-config/config.json:ro
|
|
`, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token)
|
|
}
|