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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user