Slice 4: admin UI for self-updates at /-/admin/updates#136
Conversation
Adds the in-product UI that surfaces the processgit-updater sidecar (shipped in #128 / #131) to site administrators, completing the self-update story end-to-end. Three pages, all server-rendered Go templates (no JS dependency): /-/admin/updates — status page - Current ProcessGit version - Latest available release (from the sidecar's /releases/latest) - "Update available" banner + Install button when newer - Active-job banner with link to detail when an update is running - History table (last N jobs) linking to per-job detail POST /-/admin/updates/install — trigger update - Posts {target_tag: ...} to the sidecar's /update endpoint - Surfaces sidecar errors via ctx.Flash.Error - Redirects to /jobs/{id} on success so the operator sees progress /-/admin/updates/jobs/{jobid} — per-job detail - Job metadata table (target, state, started, completed, error) - Per-step history with success/failure markers and step output - Plain HTML meta-refresh @ 2s while the job is non-terminal (no JS needed; works in any browser including elinks/w3m) All HTTP calls to the sidecar happen server-side from this handler. The browser never sees the bearer token, never sees the sidecar URL. Files: NEW: routers/web/admin/updates.go ~280 lines (handler + client) routers/web/admin/updates_test.go ~200 lines (8 tests) templates/admin/updates.tmpl ~100 lines templates/admin/update_job.tmpl ~90 lines templates/admin/update_state_label.tmpl ~10 lines (shared partial) MODIFIED: routers/web/web.go +7 (3 new routes) templates/admin/navbar.tmpl +5/-1 (Updates entry under Maintenance) options/locale/locale_en-US.json +56 keys under admin.* Configuration: the handler reads two env vars set by the compose file on the main container (already in #130 + #134): PROCESSGIT_UPDATER_URL e.g. http://processgit-updater:9000 PROCESSGIT_UPDATER_TOKEN shared bearer If either is unset (operator opted out of the updater), the page renders a "disabled" state with the activation instructions instead of erroring. No new settings to add to app.ini. Updater HTTP client (internal updaterClient type in updates.go): do(ctx, method, path, body, out) — single-shot JSON request with Bearer auth, 10s timeout, 16MB response cap, surfaces sidecar error bodies in errors. All four sidecar endpoints we consume are wrapped: GET /status -> UpdaterStatus GET /releases/latest -> UpdaterRelease GET /history -> []UpdaterJob POST /update -> updateStartResponse GET /update/{id} -> UpdaterJob Tests (all in updates_test.go, using httptest.NewServer as a stand-in sidecar): TestIsNewerVersion — semver-coarse compare for the "available" banner TestStripSuffix — -rc1 / +build.123 suffix handling TestUpdaterClient_AuthHeader — Bearer token threads correctly TestUpdaterClient_DisabledWhenNoEnv — nil when either env missing TestUpdaterClient_PostBodyEncoding — JSON body shape on POST /update TestUpdaterClient_HTTPErrorSurfacing — sidecar error body preserved in error TestUpdaterJob_TerminalState — IsTerminal/IsSuccess/IsFailure TestUpdaterJob_JSONShape — full-fidelity Job round-trip TestHistoryResponse_JSONShape — list endpoint shape TestUpdaterRelease_JSONShape — /releases/latest shape Notes on what's NOT in this PR (intentional scope cuts): - No live-progress via WebSocket / SSE / fetch polling. The 2-second meta-refresh on the job detail page is sufficient for the documented stub-mode wall clock (~20s end-to-end) and the real-mode wall clock (~minutes). A follow-up can add a fetch-poll Vue island if user demand emerges. - No "check for updates" button. The /releases/latest endpoint is hit on every page load, which is fine — the sidecar caches by nature of being an HTTP proxy to api.github.com. If we later add explicit caching, a manual-check button makes sense. - No abort-in-flight button. The sidecar's POST /update/{id}/abort endpoint isn't wired in updater code yet (Slice 3A only had happy-path orchestration). Adding the abort flow is its own PR. - No locale entries beyond en-US. Other locales fall through to the en-US strings via Gitea's default behavior. Co-authored-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aec5d17642
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <td>{{template "admin/update_state_label" .State}}</td> | ||
| <td>{{DateUtils.AbsoluteShort .StartedAt}}</td> | ||
| <td> | ||
| {{if .CompletedAt}}{{DateUtils.TimeSince .StartedAt}}{{else}}—{{end}} |
There was a problem hiding this comment.
Compute completed durations from completion timestamp
This cell is labeled as a duration, but DateUtils.TimeSince computes time relative to now (not elapsed between start and completion), so completed entries will drift over time and stop representing actual run length; e.g., a step that took 20s will later render as "2 hours ago." The same pattern is also used for job history/completed sections, so operators lose accurate runtime data after the job finishes.
Useful? React with 👍 / 👎.
| for i, s := range c { | ||
| c[i] = stripSuffix(s) | ||
| } |
There was a problem hiding this comment.
Treat pre-release current versions as older than stable release
The comparison strips pre-release suffixes from current before numeric comparison, so 0.1.0-rc1 and 0.1.0 collapse to the same tuple and return "not newer." In practice this suppresses the update banner for admins running an RC when the corresponding stable release is available, which is exactly when they should be prompted to upgrade.
Useful? React with 👍 / 👎.
Slice 4 — Admin UI for self-updates at
/-/admin/updatesThe visible payoff. Completes the in-product self-update story end-to-end. Site admins now have a real page to check for and install updates without curling the sidecar's internal API by hand.
Three pages, server-rendered, zero JS dependencies
GET /-/admin/updatesPOST /-/admin/updates/installGET /-/admin/updates/jobs/{id}<meta http-equiv=refresh>while non-terminalAll HTTP calls to the sidecar happen server-side from this handler. The browser never sees the bearer token or the sidecar URL.
What the operator sees
No sidecar configured (operator opted out per the README's "opt out" path):
Up-to-date:
Update available:
During an update (redirected to
/jobs/{id}after clicking Install):File inventory
routers/web/admin/updates.gorouters/web/admin/updates_test.gotemplates/admin/updates.tmpltemplates/admin/update_job.tmpltemplates/admin/update_state_label.tmplstacktrace-row.tmplconvention)routers/web/web.go/-/admingroup, after/monitor)templates/admin/navbar.tmploptions/locale/locale_en-US.jsonadmin.updates.*Total: ~750 lines of new content.
Implementation details worth noting
Config via env vars, not app.ini. The handler reads
PROCESSGIT_UPDATER_URLandPROCESSGIT_UPDATER_TOKENdirectly viaos.Getenv— these are already set on the main container by the compose file (#130 + #134). No newapp.inikeys to plumb throughmodules/setting, which keeps the surface tight.No JS, deliberately. A
<meta http-equiv="refresh" content="2">on the job detail page handles live-updating while a job is active. This works in every browser, leaves no client-side state, and matches the natural granularity of the sidecar's state machine. A follow-up can add a Vue/fetch island if real-time feedback becomes a need; today's 2s refresh is more than sufficient for stub-mode (~20s end-to-end) and real-mode (~minutes).Sidecar HTTP client is internal.
updaterClientlives inupdates.gowith a singledo(ctx, method, path, body, out)method. ~30 lines. No retries, no rate limiting, no circuit breaker — the sidecar is on the local docker network and either responds in <1s or doesn't. Failures surface to the operator viactx.Flash.Error.Coarse semver compare for the "available" banner. Numeric tuple compare with
-rc1/+build.foosuffix stripping. Works for every realistic release pair. A real semver lib (golang.org/x/mod/semver) is overkill for this one comparison and would add a dep.State-label partial. Eleven possible job states (idle / planning / snapshotting / pulling / verifying / migrating / swapping / healthchecking / rolling_back / committed / rolled_back / failed / aborted) get color-coded labels via a shared
admin/update_state_labeltemplate used by both the status page (history table) and the detail page (state field + per-step rows).Sanity checks done
gofmt -lclean on the new Go filespython3 -c 'json.load'confirms the locale JSON parsesgrep -c '{{' == grep -c '}}'for all 3 new templates)ctx.PathParam,ctx.HTML,ctx.Flash,ctx.Trusage confirmed against existing admin handlers (users.go,notice.go)DateUtils.AbsoluteShort/FullTime/TimeSinceconfirmed against existing admin templates (notice.tmpl,cron.tmpl,stacktrace-row.tmpl)http.NewRequestWithContext(ctx, ...)confirmed againstservices/migrations/codebase.go(same pattern with a Gitea context)t.Context()(Go 1.24+) used in existing tests (routers/web/user/home_test.go)Could not run
go vet/go buildlocally because the sandbox has Go 1.22 and the repo'sgo.modtargets Go 1.25 with directives (godebug,tool,ignore) that 1.22 doesn't understand. CI on the PR will verify; failure modes here are limited to typos.What's deliberately out of scope
/releases/latestis fetched on every page load — fine because the sidecar mediates and we'll add caching there if needed.POST /update/{id}/abortisn't implemented in updater code yet (only happy-path orchestration in Slice 3A: processgit-updater sidecar (state machine + HTTP API + cosign verify) #128).en-US. Other locales fall through to en-US strings via Gitea's default behavior. Translators can add keys post-merge.What ships once this merges + v0.1.1 is tagged
The whole story works end-to-end:
/releases/latestreports v0.1.1/-/admin/updates, sees the banner, clicks Install v0.1.1docker compose up --no-deps processgit/-/admin/updatesshows v0.1.1 as current and no update availableOr — if healthcheck fails — rollback runs, admin sees the rolled_back state with the failure point in the per-step output. Either way it's visible, observable, and recoverable.
Sequencing
No dependencies on unmerged PRs. Can merge immediately. Will be useful the moment a v0.1.1 ships — until then, the page shows "you're on latest" once it can reach the sidecar.