Compare commits

..
Author SHA1 Message Date
shcizo 96caf4a8a7 fix(master): keep the scroll position when toggling auto mode on the overview
The mode switch was a plain form POST answered with 303 to /, so the browser
navigated to a fresh page at the top. The form now also posts via htmx and
the handler returns the services fragment for htmx requests, so the list is
swapped in place; search and machine filter stay applied. A refused auto
answers 409 with HX-Redirect to the service page, since htmx does not swap
4xx responses. Without JS the form still posts and redirects as before.

#services gets overflow-anchor:none. htmx inserts the new list before
removing the old one, and Chrome anchored scrolling on the focused switch,
so on narrow screens the page jumped to the bottom after a click, and after
any SSE refresh while a switch had focus.

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
2026-10-13 12:59:10 +02:00
11 changed files with 18 additions and 367 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello - `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello
- `internal/master/registry` — HEAD-only digest lookup and tag listing against a registry (go-containerregistry, `DOCKER_CONFIG` credentials) - `internal/master/registry` — HEAD-only digest lookup and tag listing against a registry (go-containerregistry, `DOCKER_CONFIG` credentials)
- `internal/master/tagver` — which tag is a newer version of a followed tag (pure functions) - `internal/master/tagver` — which tag is a newer version of a followed tag (pure functions)
- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion; `PollService` does the same for one service (UI Check now) - `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent - `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
- `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them - `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them
- `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons` - `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons`
-3
View File
@@ -156,9 +156,6 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s
- **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and - **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and
services without a registry digest (locally built images). The service page says which. services without a registry digest (locally built images). The service page says which.
Plain containers are polled, so their badge works, but jobs for them are refused. Plain containers are polled, so their badge works, but jobs for them are refused.
- **Check now.** The service page's *Check now* button runs the same check immediately for that
service only (digest, auto job, newer tag); other services that follow the same tag are
updated at the next cycle.
- **Errors.** A failed lookup is stored for the services that follow that tag and shown as a - **Errors.** A failed lookup is stored for the services that follow that tag and shown as a
"poll failed" badge on the overview and in the "Polling" row of the service page; other "poll failed" badge on the overview and in the "Polling" row of the service page; other
tags continue. A later success clears it. tags continue. A later success clears it.
+10 -11
View File
@@ -115,17 +115,7 @@ func run() error {
} }
auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger) auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger)
credDir, credFound, credReadErr := registryCredentialsDir() ui, err := web.New(st, agentHub, jobService, broker, web.Config{
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
"config_json_readable", credFound && credReadErr == nil)
if credReadErr != nil {
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
}
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
}, logger)
ui, err := web.New(st, agentHub, jobService, broker, registryPoller, web.Config{
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage, AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
PollInterval: cfg.PollInterval, Version: version, PollInterval: cfg.PollInterval, Version: version,
}, logger) }, logger)
@@ -174,8 +164,17 @@ func run() error {
BaseContext: baseContext, BaseContext: baseContext,
} }
credDir, credFound, credReadErr := registryCredentialsDir()
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
"config_json_readable", credFound && credReadErr == nil)
if credReadErr != nil {
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
}
pollCtx, stopPolling := context.WithCancel(ctx) pollCtx, stopPolling := context.WithCancel(ctx)
defer stopPolling() defer stopPolling()
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
}, logger)
pollDone := make(chan struct{}) pollDone := make(chan struct{})
go func() { go func() {
defer close(pollDone) defer close(pollDone)
+3 -45
View File
@@ -139,17 +139,6 @@ func (p *Poller) Cycle(ctx context.Context) {
p.log.Error("poll_list_failed", "error", err) p.log.Error("poll_list_failed", "error", err)
return return
} }
stats := p.pollServices(ctx, svcs)
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
"duration_ms", p.now().Sub(began).Milliseconds())
}
// pollServices does the work of a cycle for svcs: it looks up every distinct
// followed ref once, records the results and starts auto jobs, then syncs the
// newer-tag suggestions of svcs.
func (p *Poller) pollServices(ctx context.Context, svcs []model.Service) cycleStats {
var stats cycleStats var stats cycleStats
byRef := map[string][]model.Service{} byRef := map[string][]model.Service{}
var refs []string var refs []string
@@ -181,41 +170,10 @@ func (p *Poller) pollServices(ctx context.Context, svcs []model.Service) cycleSt
p.publishIfWrote(&stats) p.publishIfWrote(&stats)
p.syncNewer(ctx, write, svcs, &stats) p.syncNewer(ctx, write, svcs, &stats)
p.publishIfWrote(&stats) p.publishIfWrote(&stats)
return stats p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
} "suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
// NotPolledError says a service cannot be checked against the registry.
type NotPolledError struct{ Reason string }
func (e *NotPolledError) Error() string { return "not polled: " + e.Reason }
// PollService does what Cycle does, for the service id only: it looks up the
// followed ref, records the digest, starts an auto job if one is wanted and
// syncs the newer-tag suggestion. Other services that follow the same ref are
// updated at the next cycle. It is safe to run while Run is running: the
// unique index update_jobs_one_active prevents duplicate jobs. A registry
// error is not returned; it is stored as last_poll_error, like in a cycle. It
// returns the store's error when the service cannot be read, model.ErrNotFound
// for a removed service, *NotPolledError when the service is not polled, and
// ctx.Err() when ctx ended.
func (p *Poller) PollService(ctx context.Context, id int64) error {
began := p.now()
svc, err := p.store.GetService(ctx, id)
if err != nil {
return err
}
if svc.RemovedAt != nil {
return model.ErrNotFound
}
if r := SkipReason(svc); r != "" {
return &NotPolledError{Reason: r}
}
stats := p.pollServices(ctx, []model.Service{svc})
p.log.Info("poll_service", "service_id", id, "ref", jobs.FollowedRef(svc.ActualRef),
"ok", stats.ok, "failed", stats.failed, "suggested", stats.suggested, "jobs", stats.jobs,
"tags_failed", stats.tagsFailed, "newer", stats.newer,
"duration_ms", p.now().Sub(began).Milliseconds()) "duration_ms", p.now().Sub(began).Milliseconds())
return ctx.Err()
} }
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote // publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
-157
View File
@@ -938,160 +938,3 @@ func TestDigestWritesArePublishedBeforeTagsAreListed(t *testing.T) {
close(reg.tagRelease) close(reg.tagRelease)
<-done <-done
} }
func TestPollService(t *testing.T) {
ref := repoA + ":latest" // not version-like: no tag listing
newDigest := map[string]string{ref: "sha256:new"}
auto := func(edit func(*model.Service)) model.Service {
s := svc(1, ref)
s.UpdateMode = model.ModeAuto
if edit != nil {
edit(&s)
}
return s
}
tests := []struct {
name string
svc model.Service
reg *fakeRegistry
startErr error
wantErr error // matched with ErrorIs
wantNotPoll string
wantHeads []string
wantTags []string
check func(t *testing.T, st *fakeStore, j *fakeJobs)
}{
{name: "manual with a new digest is only marked",
svc: svc(1, ref), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
require.Len(t, st.available, 1)
assert.Equal(t, model.SourcePoll, st.available[0].a.Source)
assert.Empty(t, j.starts)
}},
{name: "auto with a new digest starts a poll job",
svc: auto(nil), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
assert.Equal(t, []startCall{{1, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref}}}, j.starts)
assert.Equal(t, []lastTagCall{{1, "sha256:new"}}, st.lastTag)
}},
{name: "auto that cannot be updated is marked but last_tag_digest stays",
svc: auto(nil), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
startErr: &model.NotUpdatableError{Reason: model.ReasonDrifted},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
assert.Len(t, st.available, 1)
assert.Len(t, j.starts, 1)
assert.Empty(t, st.lastTag)
}},
{name: "auto that already got the digest is not started",
svc: auto(func(s *model.Service) { s.LastTagDigest = "sha256:new" }),
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) { assert.Empty(t, j.starts) }},
{name: "auto whose desired_ref names the digest is not started",
svc: auto(func(s *model.Service) { s.DesiredRef = ref + "@sha256:new" }),
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) { assert.Empty(t, j.starts) }},
{name: "registry error is stored, not returned",
svc: svc(1, ref), reg: &fakeRegistry{errs: map[string]error{ref: errors.New("head: boom")}}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
assert.Equal(t, []pollErrorCall{{[]int64{1}, "head: boom"}}, st.pollErrors)
}},
{name: "a previous poll error is cleared on success",
svc: auto(func(s *model.Service) {
s.UpdateMode, s.ActualDigest, s.LastPollError = model.ModeManual, "sha256:new", "head: boom"
}),
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
assert.Equal(t, []pollErrorCall{{[]int64{1}, ""}}, st.pollErrors)
}},
{name: "no tag is not polled",
svc: svc(1, repoA+"@sha256:old"), reg: &fakeRegistry{}, wantNotPoll: model.ReasonNoTag},
{name: "built is not polled",
svc: auto(func(s *model.Service) { s.Built = true }), reg: &fakeRegistry{}, wantNotPoll: "built from source"},
{name: "no digest is not polled",
svc: auto(func(s *model.Service) { s.ActualDigest = "" }), reg: &fakeRegistry{}, wantNotPoll: "no registry digest"},
{name: "removed is not found",
svc: auto(func(s *model.Service) { s.RemovedAt = ptr(time.Now()) }), reg: &fakeRegistry{}, wantErr: model.ErrNotFound},
{name: "a comparable tag lists the repo once and suggests the newer tag",
svc: newerSvc(1, repoB, "1.27.3"),
reg: newerRegistry(map[string][]string{repoB: {"1.27.4", "1.28.0"}}, repoB+":1.27.3"),
wantHeads: []string{repoB + ":1.27.3"}, wantTags: []string{repoB},
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
require.Len(t, st.newer, 1)
assert.Equal(t, newerCall{1, repoB + ":1.28.0", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}, st.newer[0])
}},
{name: "a tag that cannot have a newer tag lists nothing",
svc: newerSvc(1, repoB, "latest"), reg: newerRegistry(nil, repoB+":latest"), wantHeads: []string{repoB + ":latest"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &fakeStore{services: []model.Service{tt.svc}}
j := &fakeJobs{errFor: map[int64]error{1: tt.startErr}}
p, _ := newPoller(st, j, tt.reg, Config{})
err := p.PollService(context.Background(), 1)
var np *NotPolledError
switch {
case tt.wantNotPoll != "":
require.ErrorAs(t, err, &np)
assert.Equal(t, tt.wantNotPoll, np.Reason)
case tt.wantErr != nil:
assert.ErrorIs(t, err, tt.wantErr)
default:
require.NoError(t, err)
}
assert.Equal(t, tt.wantHeads, tt.reg.sortedCalls())
assert.Equal(t, tt.wantTags, tt.reg.tagCalls)
if tt.check != nil {
tt.check(t, st, j)
}
})
}
}
func TestPollServiceUnknownID(t *testing.T) {
reg := &fakeRegistry{}
p, _ := newPoller(&fakeStore{}, &fakeJobs{}, reg, Config{})
err := p.PollService(context.Background(), 9)
assert.ErrorIs(t, err, model.ErrNotFound)
assert.Empty(t, reg.calls)
}
func TestPollServiceOnlyThisService(t *testing.T) {
ref := repoA + ":1.4"
st := &fakeStore{services: []model.Service{svc(1, ref), svc(2, ref)}}
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
require.NoError(t, p.PollService(context.Background(), 1))
require.Len(t, st.available, 1)
assert.Equal(t, int64(1), st.available[0].id)
assert.Equal(t, []string{ref}, reg.sortedCalls())
for _, c := range st.pollErrors {
assert.NotContains(t, c.ids, int64(2))
}
for _, c := range st.newer {
assert.Equal(t, int64(1), c.id)
}
for _, c := range st.lastTag {
assert.Equal(t, int64(1), c.id)
}
}
func TestPollServiceCancelled(t *testing.T) {
ref := repoA + ":1.4"
st := &fakeStore{services: []model.Service{svc(1, ref)}}
reg := &fakeRegistry{release: make(chan struct{})} // lookups only end by cancel
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := p.PollService(ctx, 1)
assert.ErrorIs(t, err, context.Canceled)
assert.Empty(t, st.pollErrors)
assert.Empty(t, st.available)
}
+1 -1
View File
@@ -281,7 +281,7 @@ func TestServiceDetail_DriftAndMoveData(t *testing.T) {
store := &fakeStore{services: []model.Service{live, gone, swarm, container, self}, store := &fakeStore{services: []model.Service{live, gone, swarm, container, self},
moves: []model.Move{move}, sources: sources} moves: []model.Move{move}, sources: sources}
jobs := &fakeJobs{restorable: tt.restorable} jobs := &fakeJobs{restorable: tt.restorable}
s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), &fakePoller{}, Config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err) require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil) req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil)
-86
View File
@@ -1,86 +0,0 @@
package web
import (
"context"
"io"
"net/http"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
)
// fakePoller records the services it was asked to check and answers err.
type fakePoller struct {
mu sync.Mutex
err error
calls []int64
}
func (f *fakePoller) PollService(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, id)
return f.err
}
func (f *fakePoller) called() []int64 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]int64(nil), f.calls...)
}
func TestPollService(t *testing.T) {
tests := []struct {
name string
id string
err error
wantStatus int
wantLocation string
wantBody string
wantCalls []int64
}{
{name: "redirects to the service page", id: "1", wantStatus: http.StatusSeeOther, wantLocation: "/services/1", wantCalls: []int64{1}},
{name: "unknown service is 404", id: "1", err: model.ErrNotFound, wantStatus: http.StatusNotFound, wantCalls: []int64{1}},
{name: "invalid id is 400", id: "abc", wantStatus: http.StatusBadRequest},
{name: "not polled is 409 with the reason", id: "1", err: &poller.NotPolledError{Reason: model.ReasonNoTag},
wantStatus: http.StatusConflict, wantBody: "Cannot check: follows no tag.", wantCalls: []int64{1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fp := &fakePoller{err: tt.err}
srv, _, _, _, _ := newTestEnvFull(t, func(*Config) {}, fp)
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+tt.id+"/poll", url.Values{"csrf_token": {"csrf"}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, tt.wantStatus, resp.StatusCode)
assert.Equal(t, tt.wantLocation, resp.Header.Get("Location"))
assert.Contains(t, string(body), tt.wantBody)
assert.Equal(t, tt.wantCalls, fp.called())
})
}
}
func TestServicePage_CheckNowForm(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ActualDigest = "sha256:" + "a"
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, `action="/services/1/poll"`)
assert.Contains(t, body, `<button type="submit">Check now</button>`)
assert.NotContains(t, body, "Check unavailable")
}
func TestServicePage_CheckNowDisabledWhenNotPolled(t *testing.T) {
srv, _, _, _, _ := newTestEnv(t)
_, body := get(t, srv, "/services/2") // follows no tag
assert.Contains(t, body, `<button type="submit" disabled>Check now</button>`)
assert.Contains(t, body, "Check unavailable: follows no tag.")
}
+2 -42
View File
@@ -18,7 +18,6 @@ import (
"github.com/shcizo/package-updater/internal/agenttoken" "github.com/shcizo/package-updater/internal/agenttoken"
"github.com/shcizo/package-updater/internal/master/events" "github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
"github.com/shcizo/package-updater/internal/master/web/icons" "github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/master/webauth" "github.com/shcizo/package-updater/internal/master/webauth"
"github.com/shcizo/package-updater/internal/protocol" "github.com/shcizo/package-updater/internal/protocol"
@@ -86,13 +85,6 @@ type Jobs interface {
RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
} }
// Poller checks the registry for one service. *poller.Poller satisfies it.
type Poller interface {
// PollService returns *poller.NotPolledError when the service is not
// polled and model.ErrNotFound for an unknown or removed one.
PollService(ctx context.Context, id int64) error
}
// Broker delivers change signals to open streams and accepts signals from // Broker delivers change signals to open streams and accepts signals from
// UI actions. *events.Broker satisfies it. // UI actions. *events.Broker satisfies it.
type Broker interface { type Broker interface {
@@ -116,7 +108,6 @@ type Server struct {
hub Hub hub Hub
jobs Jobs jobs Jobs
broker Broker broker Broker
poller Poller
cfg Config cfg Config
log *slog.Logger log *slog.Logger
now func() time.Time now func() time.Time
@@ -125,7 +116,7 @@ type Server struct {
// New parses the templates. Each page is its own template set so every page // New parses the templates. Each page is its own template set so every page
// can define its own "content" block. // can define its own "content" block.
func New(store Store, hub Hub, jobs Jobs, broker Broker, poller Poller, cfg Config, log *slog.Logger) (*Server, error) { 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} funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
pages := map[string]*template.Template{} pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} { for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
@@ -135,7 +126,7 @@ func New(store Store, hub Hub, jobs Jobs, broker Broker, poller Poller, cfg Conf
} }
pages[page] = t pages[page] = t
} }
return &Server{store: store, hub: hub, jobs: jobs, broker: broker, poller: poller, cfg: cfg, log: log, now: time.Now, pages: pages}, nil 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 // Register adds the UI routes to mux. protect wraps every route except
@@ -149,7 +140,6 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment) route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
route("POST /services/{id}/update", s.updateService) route("POST /services/{id}/update", s.updateService)
route("POST /services/{id}/mode", s.setMode) route("POST /services/{id}/mode", s.setMode)
route("POST /services/{id}/poll", s.pollService)
route("POST /services/{id}/restore", s.restoreDesired) route("POST /services/{id}/restore", s.restoreDesired)
route("POST /services/{id}/accept", s.acceptRunning) route("POST /services/{id}/accept", s.acceptRunning)
route("POST /services/{id}/merge", s.mergeService) route("POST /services/{id}/merge", s.mergeService)
@@ -214,7 +204,6 @@ type pageData struct {
ConfigFiles []string ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can Reason string // why Service cannot be updated; "" when it can
AutoBlocked string // why Service cannot be set to auto; "" when it can AutoBlocked string // why Service cannot be set to auto; "" when it can
PollBlocked string // why Service cannot be checked against the registry; "" when it can
Versions []versionRow Versions []versionRow
NewerTag string // Service's newer tag while it still holds; "" otherwise NewerTag string // Service's newer tag while it still holds; "" otherwise
Polling string // what the service page says about registry polling Polling string // what the service page says about registry polling
@@ -391,7 +380,6 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err return data, err
} }
data.AutoBlocked = s.autoBlocked(svc) data.AutoBlocked = s.autoBlocked(svc)
data.PollBlocked = pollBlocked(svc)
if err := s.jobs.Restorable(svc); errors.As(err, &nu) { if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
data.RestoreBlocked = nu.Reason data.RestoreBlocked = nu.Reason
} else if err != nil { } else if err != nil {
@@ -617,34 +605,6 @@ func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id i
s.page(w, r, status, "service.html", data) s.page(w, r, status, "service.html", data)
} }
// pollService checks the registry for one service now and waits for the
// answer. A registry error is not a failure of the request: it shows on the
// service page as the last check failing. The poller publishes the change
// itself when it writes.
func (s *Server) pollService(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
err := s.poller.PollService(r.Context(), id)
var np *poller.NotPolledError
switch {
case errors.As(err, &np):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot check: "+np.Reason+".")
return
case r.Context().Err() != nil:
// The client is gone or the master is shutting down. Redirect anyway:
// a browser still waiting gets the page, not an empty 200.
case err != nil:
s.failLookup(w, err)
return
default:
sess, _ := webauth.SessionFrom(r.Context())
s.log.Info("poll_requested", "service_id", id, "subject", sess.Subject)
}
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// setMode switches a service between manual and auto. Manual is always // setMode switches a service between manual and auto. Manual is always
// allowed; auto only when jobs.AutoAllowed says so. // allowed; auto only when jobs.AutoAllowed says so.
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) { func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
+1 -7
View File
@@ -232,12 +232,6 @@ func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeSto
// newTestEnvConfig is newTestEnv with edit applied to the UI's Config. // newTestEnvConfig is newTestEnv with edit applied to the UI's Config.
func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
return newTestEnvFull(t, edit, &fakePoller{})
}
// newTestEnvFull is newTestEnvConfig with p as the poller.
func newTestEnvFull(t *testing.T, edit func(*Config), p Poller) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper() t.Helper()
agentID, offlineAgentID := int64(2), int64(3) agentID, offlineAgentID := int64(2), int64(3)
store := &fakeStore{ store := &fakeStore{
@@ -266,7 +260,7 @@ func newTestEnvFull(t *testing.T, edit func(*Config), p Poller) (*httptest.Serve
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
} }
edit(&cfg) edit(&cfg)
s, err := New(store, hub, jobs, broker, p, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err) require.NoError(t, err)
mux := http.NewServeMux() mux := http.NewServeMux()
s.Register(mux, func(h http.Handler) http.Handler { s.Register(mux, func(h http.Handler) http.Handler {
@@ -23,11 +23,6 @@
</form> </form>
<p class="muted">Pulls the current tag again and redeploys the service.</p> <p class="muted">Pulls the current tag again and redeploys the service.</p>
{{end}} {{end}}
<form method="post" action="/services/{{.Service.ID}}/poll">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit"{{if .PollBlocked}} disabled{{end}}>Check now</button>
<p class="muted">{{if .PollBlocked}}Check unavailable: {{.PollBlocked}}.{{else}}Asks the registry now instead of waiting for the next poll.{{end}}</p>
</form>
<form method="post" action="/services/{{.Service.ID}}/mode"> <form method="post" action="/services/{{.Service.ID}}/mode">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"> <input type="hidden" name="csrf_token" value="{{.CSRF}}">
{{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual"> {{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
-9
View File
@@ -281,15 +281,6 @@ func versionRows(versions []model.ImageVersion, actualDigest string) []versionRo
return rows return rows
} }
// pollBlocked returns why the registry cannot be checked for svc, or "" when
// it can.
func pollBlocked(svc model.Service) string {
if svc.RemovedAt != nil {
return model.ReasonRemoved
}
return poller.SkipReason(svc)
}
// pollingStatus is the text of the "Polling" row on the service page and // pollingStatus is the text of the "Polling" row on the service page and
// whether it is a registry error. // whether it is a registry error.
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) { func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {