Skip to content

Slice 4: admin UI for self-updates at /-/admin/updates#136

Merged
rg4444 merged 1 commit into
mainfrom
slice-4/admin-updates-ui
May 23, 2026
Merged

Slice 4: admin UI for self-updates at /-/admin/updates#136
rg4444 merged 1 commit into
mainfrom
slice-4/admin-updates-ui

Conversation

@rg4444
Copy link
Copy Markdown
Contributor

@rg4444 rg4444 commented May 23, 2026

Slice 4 — Admin UI for self-updates at /-/admin/updates

The 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

Route What
GET /-/admin/updates Status page — current version, latest available, install button when newer, active-job banner, last N jobs
POST /-/admin/updates/install Trigger an update; redirects to job detail on success
GET /-/admin/updates/jobs/{id} Per-job detail — metadata, per-step history, auto-refresh via <meta http-equiv=refresh> while non-terminal

All 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):

The self-update sidecar is not configured for this deployment.
To enable it, set the bearer token in deploy/.env and start the sidecar service: …

Up-to-date:

Current version: 0.1.1
Latest release: v0.1.1 · 2 days ago
✅ You are on the latest stable release.

Update available:

Current version: 0.1.0
Latest release: v0.1.1 · 1 hour ago

🟢 An update is available
v0.1.1 is available. The updater will pull the signed image, run any migration, and swap the running container with an automatic rollback if the healthcheck fails.

[Install v0.1.1]

During an update (redirected to /jobs/{id} after clicking Install):

Job ID: job_a1b2c3d4
Target: v0.1.1 (sha256:…)
State: 🔵 Pulling image
Started: 2026-05-23 18:00:00

Per-step table with ✓/⏳/✗ markers

🔄 Update in progress — this page refreshes every 2 seconds.

File inventory

Status File Size
✚ new routers/web/admin/updates.go ~280 lines (handler + sidecar HTTP client + types)
✚ new routers/web/admin/updates_test.go ~200 lines (10 tests)
✚ new templates/admin/updates.tmpl ~100 lines
✚ new templates/admin/update_job.tmpl ~90 lines
✚ new templates/admin/update_state_label.tmpl ~10 lines (shared partial, matches stacktrace-row.tmpl convention)
✎ mod routers/web/web.go +7 (3 new routes inside the existing /-/admin group, after /monitor)
✎ mod templates/admin/navbar.tmpl +5/-1 (Updates entry under Maintenance, alongside Dashboard + Self Check)
✎ mod options/locale/locale_en-US.json +56 keys under admin.updates.*

Total: ~750 lines of new content.

Implementation details worth noting

Config via env vars, not app.ini. The handler reads PROCESSGIT_UPDATER_URL and PROCESSGIT_UPDATER_TOKEN directly via os.Getenv — these are already set on the main container by the compose file (#130 + #134). No new app.ini keys to plumb through modules/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. updaterClient lives in updates.go with a single do(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 via ctx.Flash.Error.

Coarse semver compare for the "available" banner. Numeric tuple compare with -rc1 / +build.foo suffix 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_label template used by both the status page (history table) and the detail page (state field + per-step rows).

Sanity checks done

  • gofmt -l clean on the new Go files
  • python3 -c 'json.load' confirms the locale JSON parses
  • Template brace counts balanced (grep -c '{{' == grep -c '}}' for all 3 new templates)
  • ctx.PathParam, ctx.HTML, ctx.Flash, ctx.Tr usage confirmed against existing admin handlers (users.go, notice.go)
  • DateUtils.AbsoluteShort / FullTime / TimeSince confirmed against existing admin templates (notice.tmpl, cron.tmpl, stacktrace-row.tmpl)
  • http.NewRequestWithContext(ctx, ...) confirmed against services/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 build locally because the sandbox has Go 1.22 and the repo's go.mod targets 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

  • No live WebSocket / SSE updates. 2-second meta-refresh is sufficient.
  • No "Check for updates" button. /releases/latest is fetched on every page load — fine because the sidecar mediates and we'll add caching there if needed.
  • No abort-in-flight button. The sidecar's POST /update/{id}/abort isn't implemented in updater code yet (only happy-path orchestration in Slice 3A: processgit-updater sidecar (state machine + HTTP API + cosign verify) #128).
  • No locales beyond en-US. Other locales fall through to en-US strings via Gitea's default behavior. Translators can add keys post-merge.
  • No version-pinning UI (e.g., "install v0.1.2 instead of latest"). The Install button always targets the latest stable. CLI / API access via the sidecar still allows pinning to a specific tag.

What ships once this merges + v0.1.1 is tagged

The whole story works end-to-end:

  1. Operator pins to v0.1.0 by following the README's quickstart
  2. v0.1.1 is published (any reason — a hotfix, a feature, whatever)
  3. The sidecar's /releases/latest reports v0.1.1
  4. Admin opens /-/admin/updates, sees the banner, clicks Install v0.1.1
  5. Sidecar pulls + verifies + (optionally) migrates + swaps via docker compose up --no-deps processgit
  6. Healthcheck passes → committed
  7. Admin's page refreshes one last time, shows ✅ committed
  8. Next visit to /-/admin/updates shows v0.1.1 as current and no update available

Or — 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.

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>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +314 to +316
for i, s := range c {
c[i] = stripSuffix(s)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@rg4444 rg4444 merged commit 0a719de into main May 23, 2026
11 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant