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)
}
+227 -15
View File
@@ -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,19 +29,41 @@ 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) 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) {
@@ -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")
var got []events.Topic
for len(got) < 2 {
select {
case topic := <-ch:
assert.Equal(t, events.Services, topic, "the overview must drop the revoked agent's services")
got = append(got, topic)
default:
t.Fatal("revoke published no services event")
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, "&lt;b&gt;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)
}
}
+7
View File
@@ -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; }
+21
View File
@@ -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}}
+27
View File
@@ -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}}">
+1 -1
View File
@@ -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}}
+13
View File
@@ -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()
}
+6 -1
View File
@@ -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)
+7
View File
@@ -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)
}