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)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"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"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -28,20 +29,42 @@ import (
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
services []model.Service
|
||||
jobs []model.Job
|
||||
agents []model.Agent
|
||||
created map[string]string // name → token hash
|
||||
revokedID int64
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
||||
func (f *fakeStore) GetService(context.Context, int64) (model.Service, error) {
|
||||
func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) {
|
||||
for _, s := range f.services {
|
||||
if s.ID == id {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return model.Service{}, model.ErrNotFound
|
||||
}
|
||||
func (f *fakeStore) GetJob(context.Context, int64) (model.Job, error) {
|
||||
|
||||
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
||||
for _, j := range f.jobs {
|
||||
if j.ID == id {
|
||||
return j, nil
|
||||
}
|
||||
}
|
||||
return model.Job{}, model.ErrNotFound
|
||||
}
|
||||
func (f *fakeStore) ListJobs(context.Context, model.JobFilter) ([]model.Job, error) { return nil, nil }
|
||||
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
|
||||
|
||||
func (f *fakeStore) ListJobs(_ context.Context, flt model.JobFilter) ([]model.Job, error) {
|
||||
var out []model.Job
|
||||
for _, j := range f.jobs {
|
||||
if flt.ServiceID == 0 || j.ServiceID == flt.ServiceID {
|
||||
out = append(out, j)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
|
||||
|
||||
func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) {
|
||||
f.mu.Lock()
|
||||
@@ -72,11 +95,29 @@ func (f *fakeStore) revoked() int64 {
|
||||
return f.revokedID
|
||||
}
|
||||
|
||||
type fakeJobs struct{}
|
||||
// fakeJobs returns scripted results and records who asked for the update.
|
||||
type fakeJobs struct {
|
||||
updatable error
|
||||
job model.Job
|
||||
err error
|
||||
|
||||
func (fakeJobs) Updatable(model.Service) error { return nil }
|
||||
func (fakeJobs) Trigger(context.Context, int64, string) (model.Job, error) {
|
||||
return model.Job{}, model.ErrNotFound
|
||||
mu sync.Mutex
|
||||
requestedBy string
|
||||
}
|
||||
|
||||
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
||||
|
||||
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requestedBy = by
|
||||
return f.job, f.err
|
||||
}
|
||||
|
||||
func (f *fakeJobs) triggeredBy() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.requestedBy
|
||||
}
|
||||
|
||||
type fakeHub struct {
|
||||
@@ -104,16 +145,22 @@ func (h *fakeHub) lastDisconnected() int64 {
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) {
|
||||
t.Helper()
|
||||
srv, store, hub, broker, _ := newTestEnv(t)
|
||||
return srv, store, hub, broker
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||
t.Helper()
|
||||
agentID := int64(2)
|
||||
store := &fakeStore{
|
||||
created: map[string]string{"node-2": "x"},
|
||||
services: []model.Service{
|
||||
{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
||||
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
||||
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
||||
{Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
||||
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
||||
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
||||
{Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
||||
},
|
||||
agents: []model.Agent{
|
||||
@@ -124,15 +171,21 @@ func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *event
|
||||
}
|
||||
hub := &fakeHub{online: map[int64]bool{2: true}}
|
||||
broker := events.NewBroker()
|
||||
s, err := New(store, hub, fakeJobs{}, broker, Config{
|
||||
jobs := &fakeJobs{}
|
||||
s, err := New(store, hub, jobs, broker, Config{
|
||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
require.NoError(t, err)
|
||||
mux := http.NewServeMux()
|
||||
s.Register(mux, func(h http.Handler) http.Handler { return h })
|
||||
s.Register(mux, func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := webauth.WithSession(r.Context(), model.Session{Subject: "alice", CSRFToken: "csrf"})
|
||||
h.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, store, hub, broker
|
||||
return srv, store, hub, broker, jobs
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -233,12 +286,17 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
|
||||
assert.Equal(t, "/agents", resp.Header.Get("Location"))
|
||||
assert.Equal(t, int64(2), store.revoked())
|
||||
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
||||
select {
|
||||
case topic := <-ch:
|
||||
assert.Equal(t, events.Services, topic, "the overview must drop the revoked agent's services")
|
||||
default:
|
||||
t.Fatal("revoke published no services event")
|
||||
var got []events.Topic
|
||||
for len(got) < 2 {
|
||||
select {
|
||||
case topic := <-ch:
|
||||
got = append(got, topic)
|
||||
default:
|
||||
t.Fatalf("revoke published %v, want services and jobs", got)
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs}, got,
|
||||
"the overview drops the agent's services and job lists show its failed jobs")
|
||||
}
|
||||
|
||||
func TestRevokeAgent_BadID(t *testing.T) {
|
||||
@@ -277,3 +335,157 @@ func TestEvents_StreamsTopics(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, "event: services\n", line)
|
||||
}
|
||||
|
||||
func TestAgentSnippet(t *testing.T) {
|
||||
snippet := agentSnippet(Config{AgentURL: "wss://m/agent", MasterKey: "sha256:abc", AgentImage: "img:1"}, "tok")
|
||||
assert.NotContains(t, snippet, "STACKS_ROOT")
|
||||
assert.Contains(t, snippet, "UPDATE_TIMEOUT: 5m")
|
||||
assert.Contains(t, snippet, " # DOCKER_CONFIG: /docker-config\n")
|
||||
assert.Contains(t, snippet, " # - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write\n")
|
||||
assert.Contains(t, snippet, "AGENT_TOKEN: tok")
|
||||
}
|
||||
|
||||
func TestOverview_LinksToServicePage(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
_, body := get(t, srv, "/")
|
||||
assert.Contains(t, body, `href="/services/1"`)
|
||||
assert.Contains(t, body, `href="/jobs"`)
|
||||
}
|
||||
|
||||
func TestServicePage_ShowsUpdateButton(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].ConfigFiles = "/srv/slusk/compose.yaml,/srv/slusk/compose.override.yaml"
|
||||
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1", RequestedBy: "alice", CreatedAt: time.Now()}}
|
||||
code, body := get(t, srv, "/services/1")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, `action="/services/1/update"`)
|
||||
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
||||
assert.Contains(t, body, "/srv/slusk/compose.override.yaml")
|
||||
assert.Contains(t, body, `href="/jobs/7"`)
|
||||
assert.Contains(t, body, "sse:jobs")
|
||||
}
|
||||
|
||||
func TestServicePage_DisabledReason(t *testing.T) {
|
||||
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved} {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.updatable = &model.NotUpdatableError{Reason: reason}
|
||||
code, body := get(t, srv, "/services/1")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, reason)
|
||||
assert.NotContains(t, body, `action="/services/1/update"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePage_NotFoundAndBadID(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
code, _ := get(t, srv, "/services/99")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
code, _ = get(t, srv, "/services/abc")
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func postUpdate(t *testing.T, srv *httptest.Server) (*http.Response, string) {
|
||||
t.Helper()
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(body)
|
||||
}
|
||||
|
||||
func TestUpdate_RedirectsToJob(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.job = model.Job{ID: 42}
|
||||
resp, _ := postUpdate(t, srv)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
|
||||
assert.Equal(t, "alice", jobs.triggeredBy())
|
||||
}
|
||||
|
||||
func TestUpdate_ActiveJobRedirectsToExisting(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.job, jobs.err = model.Job{ID: 41}, model.ErrJobActive
|
||||
resp, _ := postUpdate(t, srv)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, "/jobs/41", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestUpdate_NotUpdatableShows409(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.err = &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
||||
jobs.updatable = jobs.err
|
||||
resp, body := postUpdate(t, srv)
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
assert.Contains(t, body, model.ReasonNoTag)
|
||||
}
|
||||
|
||||
func TestUpdate_UnknownService404(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.err = model.ErrNotFound
|
||||
resp, _ := postUpdate(t, srv)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestServiceJobsFragment_IsNotAFullPage(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobFailed, CreatedAt: time.Now()}}
|
||||
code, body := get(t, srv, "/fragments/services/1/jobs")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.NotContains(t, body, "<html")
|
||||
assert.Contains(t, body, "/jobs/7")
|
||||
}
|
||||
|
||||
func TestJobsPage_ListsJobs(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
started, finished := time.Now(), time.Now().Add(42*time.Second)
|
||||
store.jobs = []model.Job{
|
||||
{ID: 7, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1@sha256:abc", RequestedBy: "alice", CreatedAt: started, StartedAt: &started, FinishedAt: &finished},
|
||||
{ID: 8, ServiceID: 2, ServiceName: "jellyfin_jellyfin", Status: model.JobFailed, CreatedAt: started},
|
||||
}
|
||||
for _, path := range []string{"/jobs", "/fragments/jobs"} {
|
||||
code, body := get(t, srv, path)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, `href="/jobs/7"`)
|
||||
assert.Contains(t, body, `href="/services/2"`)
|
||||
assert.Contains(t, body, "succeeded")
|
||||
assert.Contains(t, body, "failed")
|
||||
assert.Contains(t, body, "42s")
|
||||
assert.Equal(t, path == "/jobs", strings.Contains(body, "<html"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobPage_ShowsOutputAndError(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.jobs = []model.Job{{ID: 42, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobFailed,
|
||||
Output: "Pulling web\n<b>boom</b>", Error: "pull failed", OutputTruncated: true, CreatedAt: time.Now()}}
|
||||
code, body := get(t, srv, "/jobs/42")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, "Pulling web")
|
||||
assert.Contains(t, body, "<b>boom", "output is escaped")
|
||||
assert.Contains(t, body, "pull failed")
|
||||
assert.Contains(t, body, "Earlier output truncated")
|
||||
assert.Contains(t, body, `hx-trigger="sse:job-42"`)
|
||||
}
|
||||
|
||||
func TestJobFragment_RereadsFromStore(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.jobs = []model.Job{{ID: 42, ServiceID: 1, Status: model.JobRunning, Output: "step 1", CreatedAt: time.Now()}}
|
||||
code, body := get(t, srv, "/fragments/jobs/42")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.NotContains(t, body, "<html")
|
||||
assert.Contains(t, body, "step 1")
|
||||
|
||||
store.jobs[0].Output = "step 1\nstep 2"
|
||||
_, body = get(t, srv, "/fragments/jobs/42")
|
||||
assert.Contains(t, body, "step 2")
|
||||
}
|
||||
|
||||
func TestJob_NotFound(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} {
|
||||
code, _ := get(t, srv, path)
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@ pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
|
||||
.warning { color: var(--warn); }
|
||||
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
|
||||
.muted { color: var(--muted); }
|
||||
.status-job-succeeded { color: var(--ok); }
|
||||
.status-job-queued, .status-job-running { color: var(--warn); }
|
||||
.status-job-failed { color: var(--bad); }
|
||||
table.details { width: auto; }
|
||||
table.details th { padding-right: 24px; }
|
||||
pre.log { white-space: pre-wrap; }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content"}}
|
||||
<h1>Job #{{.Job.ID}}</h1>
|
||||
<p><a href="/services/{{.Job.ServiceID}}">{{.Job.ServiceName}}</a></p>
|
||||
<div id="job" hx-get="/fragments/jobs/{{.Job.ID}}" hx-trigger="sse:job-{{.Job.ID}}" hx-swap="innerHTML">
|
||||
{{template "job" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "job"}}
|
||||
<table class="details">
|
||||
<tr><th>Status</th><td class="status status-job-{{.Job.Status}}">{{.Job.Status}}</td></tr>
|
||||
<tr><th>From</th><td><code>{{shortRef .Job.FromRef}}</code></td></tr>
|
||||
<tr><th>To</th><td><code>{{shortRef .Job.ToRef}}</code></td></tr>
|
||||
<tr><th>Requested by</th><td>{{.Job.RequestedBy}}</td></tr>
|
||||
<tr><th>Created</th><td>{{.Job.CreatedAt.Format "2006-01-02 15:04:05"}}</td></tr>
|
||||
<tr><th>Duration</th><td>{{duration .Job}}</td></tr>
|
||||
</table>
|
||||
{{if .Job.Error}}<p class="error">{{.Job.Error}}</p>{{end}}
|
||||
{{if .Job.OutputTruncated}}<p class="muted">Earlier output truncated.</p>{{end}}
|
||||
<pre class="log">{{.Job.Output}}</pre>
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<h1>Jobs</h1>
|
||||
<div id="jobs" hx-get="/fragments/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
||||
{{template "jobs" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "jobs"}}
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Service</th><th>Status</th><th>From → to</th><th>Requested by</th><th>Created</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Jobs}}
|
||||
<tr>
|
||||
<td><a href="/jobs/{{.ID}}">#{{.ID}}</a></td>
|
||||
<td><a href="/services/{{.ServiceID}}">{{.ServiceName}}</a></td>
|
||||
<td class="status status-job-{{.Status}}">{{.Status}}</td>
|
||||
<td><code>{{shortRef .FromRef}}</code> → <code>{{shortRef .ToRef}}</code></td>
|
||||
<td>{{.RequestedBy}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{duration .}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No jobs yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -11,6 +11,7 @@
|
||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
||||
<nav>
|
||||
<a href="/">Services</a>
|
||||
<a href="/jobs">Jobs</a>
|
||||
<a href="/agents">Agents</a>
|
||||
<form method="post" action="/auth/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<tbody>
|
||||
{{range .Services}}
|
||||
<tr>
|
||||
<td>{{.DisplayName}}{{if .Self}} <span class="badge">managed outside</span>{{end}}</td>
|
||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}</td>
|
||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
||||
<td>{{.UpdateMode}}</td>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Service.DisplayName}}</h1>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<table class="details">
|
||||
<tr><th>Kind</th><td>{{.Service.Kind}}</td></tr>
|
||||
<tr><th>Agent</th><td>{{if .Service.AgentName}}{{.Service.AgentName}}{{else}}Swarm{{end}}</td></tr>
|
||||
<tr><th>Image</th><td><code>{{shortRef .Service.ActualRef}}</code></td></tr>
|
||||
{{if .Service.ActualDigest}}<tr><th>Digest</th><td><code>{{.Service.ActualDigest}}</code></td></tr>{{end}}
|
||||
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
||||
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
||||
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{if .Reason}}
|
||||
<p>Update unavailable: {{.Reason}}.</p>
|
||||
{{else}}
|
||||
<form method="post" action="/services/{{.Service.ID}}/update">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
||||
{{end}}
|
||||
<h2>Update history</h2>
|
||||
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
||||
{{template "history" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "history"}}
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Status</th><th>From → to</th><th>Requested by</th><th>Created</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Jobs}}
|
||||
<tr>
|
||||
<td><a href="/jobs/{{.ID}}">#{{.ID}}</a></td>
|
||||
<td class="status status-job-{{.Status}}">{{.Status}}</td>
|
||||
<td><code>{{shortRef .FromRef}}</code> → <code>{{shortRef .ToRef}}</code></td>
|
||||
<td>{{.RequestedBy}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{duration .}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6">No updates yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
@@ -49,3 +50,15 @@ type agentRow struct {
|
||||
model.Agent
|
||||
Online bool
|
||||
}
|
||||
|
||||
// jobDuration is how long a job ran, or has been running; "" before it starts.
|
||||
func jobDuration(j model.Job) string {
|
||||
if j.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if j.FinishedAt != nil {
|
||||
end = *j.FinishedAt
|
||||
}
|
||||
return end.Sub(*j.StartedAt).Round(time.Second).String()
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func (a *Auth) Require(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
|
||||
next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), sess)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,11 @@ func (a *Auth) session(r *http.Request) (model.Session, error) {
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// WithSession returns ctx carrying sess, as Require does.
|
||||
func WithSession(ctx context.Context, sess model.Session) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, sess)
|
||||
}
|
||||
|
||||
// SessionFrom returns the session Require stored in ctx.
|
||||
func SessionFrom(ctx context.Context) (model.Session, bool) {
|
||||
s, ok := ctx.Value(sessionKey{}).(model.Session)
|
||||
|
||||
@@ -247,3 +247,10 @@ func TestLogout_DeletesSession(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, store.sessions)
|
||||
}
|
||||
|
||||
func TestWithSession_RoundTrips(t *testing.T) {
|
||||
ctx := WithSession(context.Background(), model.Session{Subject: "alice"})
|
||||
sess, ok := SessionFrom(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "alice", sess.Subject)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user