feat(web): service page, manual update, jobs list and live job log (#7)

Also publishes the jobs topic on agent revoke and updates the agent
snippet to mount stacks read-write at the same path.
This commit is contained in:
2026-10-12 18:41:46 +02:00
parent 9d3a6f808c
commit 574f425267
11 changed files with 526 additions and 23 deletions
+166 -2
View File
@@ -78,9 +78,9 @@ type Server struct {
// 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}
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "agent_created.html"} {
for _, page := range []string{"overview.html", "agents.html", "agent_created.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)
@@ -97,6 +97,13 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
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("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("POST /agents", s.createAgent)
@@ -112,6 +119,12 @@ type pageData struct {
Error string
Created model.Agent
Snippet string
Service model.Service
ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can
Jobs []model.Job
Job model.Job
}
func (s *Server) base(r *http.Request, title string) pageData {
@@ -155,6 +168,151 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "overview.html", "services", data)
}
// 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.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: 20})
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.render(w, http.StatusOK, "service.html", "layout", 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: 20})
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())
job, err := s.jobs.Trigger(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):
data, derr := s.serviceDetail(r, id)
if derr != nil {
s.failLookup(w, derr)
return
}
data.Error = "Cannot update: " + nu.Reason + "."
s.render(w, http.StatusConflict, "service.html", "layout", data)
default:
s.failLookup(w, err)
}
}
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.render(w, http.StatusOK, "jobs.html", "layout", 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.render(w, http.StatusOK, "job.html", "layout", 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")
agents, err := s.store.ListAgents(r.Context())
@@ -234,6 +392,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
}
s.hub.Disconnect(id)
s.broker.Publish(events.Services) // the agent's services were marked removed
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
@@ -293,7 +452,12 @@ func agentSnippet(cfg Config, token string) string {
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)
}