// 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" "github.com/shcizo/package-updater/internal/protocol" ) //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) // GetTagVersion returns the version of repo with this tag and no digest; // model.ErrNotFound if there is none. GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, 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 // AcceptRunning sets the desired ref of a drifted service; model.ErrNotFound // for an unknown or removed service, model.ErrConflict when the running // version is no longer actualRef@actualDigest. AcceptRunning(ctx context.Context, id int64, actualRef, actualDigest, desired string) error // MoveCandidates returns the suggested moves of removed services. MoveCandidates(ctx context.Context) ([]model.Move, error) // MergeSources returns the removed services that could be merged into toID. MergeSources(ctx context.Context, toID int64) ([]model.Service, error) // MergeService moves the live service toID onto the removed fromID. MergeService(ctx context.Context, fromID, toID int64) 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) // Restorable explains why a drifted service cannot be restored. Restorable(svc model.Service) error // RestoreDesired deploys the service's desired ref again. RestoreDesired(ctx context.Context, serviceID 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 " 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("POST /services/{id}/restore", s.restoreDesired) route("POST /services/{id}/accept", s.acceptRunning) route("POST /services/{id}/merge", s.mergeService) 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 NewerTag string // Service's newer tag while it still holds; "" otherwise Polling string // what the service page says about registry polling PollFailed bool // Polling is the last registry error // RestoreBlocked is why a drifted Service cannot be restored; "" when it // can. Only meaningful when Service.Drifted(). RestoreBlocked string MoveFrom *model.Move // a live Service: the removed service it probably replaces MoveTo *model.Move // a removed Service: the live service it probably moved to MergeSources []model.Service // a live Compose or Swarm Service: removed ones to merge in 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) if err := s.jobs.Restorable(svc); errors.As(err, &nu) { data.RestoreBlocked = nu.Reason } else if err != nil { return data, err } if err := s.moveData(r, &data); err != nil { return data, err } versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit) if err != nil { return data, err } data.NewerTag = newerTag(svc) if data.NewerTag != "" && !slices.ContainsFunc(versions, func(v model.ImageVersion) bool { return v.Tag == data.NewerTag }) { // The suggested tag can be older than the listed versions; it must // still be pickable. v, err := s.store.GetTagVersion(r.Context(), svc.ImageRepo, data.NewerTag) if err == nil { versions = append(versions, v) } else if !errors.Is(err, model.ErrNotFound) { 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 } // moveData fills the move suggestion and the merge choices of the service page. func (s *Server) moveData(r *http.Request, data *pageData) error { svc := data.Service moves, err := s.store.MoveCandidates(r.Context()) if err != nil { return err } for i, m := range moves { switch svc.ID { case m.ToID: data.MoveFrom = &moves[i] case m.FromID: data.MoveTo = &moves[i] } } if svc.RemovedAt == nil && !svc.Self && svc.Kind != protocol.KindContainer { data.MergeSources, err = s.store.MergeSources(r.Context(), svc.ID) } return 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): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot update: "+nu.Reason+".") default: s.failLookup(w, err) } } // restoreDesired deploys the desired ref of a drifted service again. func (s *Server) restoreDesired(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } sess, _ := webauth.SessionFrom(r.Context()) job, err := s.jobs.RestoreDesired(r.Context(), id, 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): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot restore: "+nu.Reason+".") default: s.failLookup(w, err) } } // acceptRunning makes the running version of a drifted service the desired // one. The form's actual field is the running ref the user saw (Service.RunningRef); // it only guards against the service having changed since, the value written // comes from the row. func (s *Server) acceptRunning(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } actual := r.PostFormValue("actual") if actual == "" { http.Error(w, "missing actual", http.StatusBadRequest) return } 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 } if !svc.Drifted() { s.renderServiceError(w, r, id, http.StatusConflict, "Cannot accept: the service runs its desired version.") return } if actual != svc.RunningRef() { s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.") return } desired := svc.RunningRef() err = s.store.AcceptRunning(r.Context(), id, svc.ActualRef, svc.ActualDigest, desired) switch { case errors.Is(err, model.ErrConflict): s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.") return case err != nil: s.failLookup(w, err) return } sess, _ := webauth.SessionFrom(r.Context()) s.log.Info("drift_accepted", "service_id", id, "subject", sess.Subject, "desired", desired) 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) } // mergeService moves the live service {id} onto the removed service in the // form field "from", which keeps its history, mode and desired ref. func (s *Server) mergeService(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } from, err := strconv.ParseInt(r.PostFormValue("from"), 10, 64) if err != nil { http.Error(w, "invalid service", http.StatusBadRequest) return } err = s.store.MergeService(r.Context(), from, id) var refused *model.MergeRefusedError switch { case errors.As(err, &refused): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+refused.Reason+".") return case errors.Is(err, model.ErrJobActive): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+model.ErrJobActive.Error()+".") return case errors.Is(err, model.ErrRevoked): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the agent is revoked.") return case errors.Is(err, model.ErrConflict): s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the service changed; check it again.") return case err != nil: s.failLookup(w, err) return } sess, _ := webauth.SessionFrom(r.Context()) s.log.Info("service_merged", "from_id", from, "to_id", id, "subject", sess.Subject) s.broker.Publish(events.Services) s.broker.Publish(events.Jobs) http.Redirect(w, r, "/services/"+strconv.FormatInt(from, 10), http.StatusSeeOther) } // renderServiceError renders the service page of id with msg as its error. func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id int64, status int, msg string) { data, err := s.serviceDetail(r, id) if err != nil { s.failLookup(w, err) return } data.Error = msg s.page(w, r, status, "service.html", data) } // 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) { s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".") 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) }