Files
werkator/docs/prs/2026-09-11-PR#54-a-home-page-for-the-instance.md
miandmhoennig 024d6311df
werkator build succeeded after 01:31
Home page (#54)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #54
2026-09-11 12:22:29 +02:00

14 KiB
Raw Permalink Blame History

WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

The Problem

/ used to show the Latest Builds of the served repository — with six served repositories, one of six. Three things were missing at the one address a visitor types first.

There was no view of the instance as a whole. The Repositories page answers "which repositories are there and what happened last", but neither "what is happening right now" nor "what needs looking after": running and failed builds stand in it beside everything else.

The state of the machine was somewhere else entirely. Whether the host is healthy stands on /system and nowhere near the page a reader opens first, although it is the question one has on arrival.

And the wordmark led to a subpage. brandUrl was either /repos or the served repository, so clicking the logo landed in a list — a good list, but not a place where the application says what it is.

A fourth problem surfaced while writing the brief, and it is a privacy one rather than a design one. A private repository's rows were redacted, but its name was confirmable: /api/repos/<guessed>/builds/latest answered 200 with redacted data while an invented name answered 404. For a repository named after a customer or an unreleased product, the name is the disclosure.

Non-Goals

  • No graphs for Load, Swap, CPU wait and Mem wait on the system page itself. They have none today although SystemHealth judges by the pressure values; that is a separate PR.
  • No new API. The page is fed by GET /api/repos, GET /api/system and GET /api/system/history, which already existed; only one field is added.
  • No change to what a trusted browser sees. Everything here is about the visitor without the control token.
  • No frontend build, no framework: server-rendered Thymeleaf and the one hand-written werkator.js, as everywhere else.

The Scenarios

Feature: a private repository is not confirmable by name

Background

  • A visitor is a browser without the control token; a trusted browser has it (PR#43).
  • The unscoped routes (/latest, /api/builds/latest, …) permanently mean the served repository (ADR 0009) and name nothing.
  • The named routes (/repos/<name>/…, /api/repos/<name>/…) carry a repository name in the path.
  • RFC 0003 D8: the content of a private repository is secret — its name, its branch and build-definition names, commits, artifacts and what is in them. Its metadata are not — that a build ran, when, how long, with what outcome, and every aggregate figure of the machine.

Scenario#54.01: A named route of a private repository answers like an unserved name

So that guessing a name tells a visitor nothing, the answer must be indistinguishable from the answer for a repository this instance does not serve.

  • Given the instance serves a private repository
  • When a visitor requests a named route of it
  • Then the response is 404
    • and its status, content type and body are the same as for a name the instance does not serve at all
    • and the check is a cached lookup, so the two do not differ in their timing either
Verified by

Scenario#54.02: The unscoped route keeps serving the served repository, redacted

So that a private repository remains usable as the instance's own repository, the form that names nothing is untouched.

  • Given the served repository is private
  • When a visitor requests an unscoped route
  • Then the response is 200 with redacted rows, exactly as before this PR
Verified by

Scenario#54.03: The control token opens the named route again

  • Given the instance serves a private repository
  • When a browser holding the control token requests a named route of it
  • Then the response is 200 with unredacted data
Verified by

Feature: the bar strip of a repository

Background

  • The strip is twelve bars: the height is the build's duration, the colour its outcome.
  • It is scaled against the longest build of the same repository, never against a neighbour's, whose builds may take a hundred times as long.

Scenario#54.04: The strip holds the last twelve builds, oldest first

So that the newest build is always the rightmost bar and the strip reads left to right in time.

  • Given a repository with more than twelve recorded builds
  • When the overview is derived
  • Then the strip holds exactly twelve entries
    • and the oldest of the twelve comes first
Verified by

Scenario#54.05: A private repository keeps its strip and loses its labels

So that the mosaic stays an even grid and a passer-by still cannot tell what the repository is.

  • Given the instance serves a private repository
  • When a visitor receives the overview
  • Then the name, the branch and the commit are redacted
    • and the strip is served unchanged, because duration and outcome are metadata (D8)
Verified by

Feature: the home page

Scenario#54.06: / shows the system rail and one card per repository

  • Given the instance serves several repositories
  • When a visitor opens /
  • Then the rail carries the verdict, one row per gauge of the system page and the four tiles, each linking into /system
    • and the mosaic carries one card per repository, newest activity first, with its bar strip
    • and the page carries the watcher banner but neither the view tabs nor the repository line (D6)
Verified by

Scenario#54.07: A private repository is a card without a destination

  • Given the instance serves a private repository
  • When a visitor opens /
  • Then its card is greyed, italic and not a link
    • and it carries its bar strip, unlabelled (D4)
Verified by

Scenario#54.08: One served repository gets the home page too

So that every installation has the same entrance; the rail is the point of the page and does not depend on the number of repositories (D3).

  • Given the instance serves exactly one repository
  • When a visitor opens /
  • Then the home page appears
    • and the single card takes the whole width instead of a third of it
Verified by

Feature: light or dark, chosen by the reader

Scenario#54.09: Every page carries the switch, and a stored choice is applied before the first paint

So that the page never flashes the browser's preference before showing the chosen one.

  • Given a reader has chosen light or dark
  • When any page is loaded
  • Then the header carries the switch
    • and the choice is set on the root element by an inline script in head, before the body is parsed
    • and color-scheme follows the choice, so form controls and scrollbars do not stay on the browser's idea of it
Verified by

The Solution

The route (D2). / is the instance's home page; the repository's Latest view moved to /latest, reachable scoped as /repos/<name> and /repos/<name>/latest. The AGENTS.md invariant that the unscoped routes permanently mean the served repository survives intact, because /latest, /branches and /history still do — only / now belongs to the instance. The wordmark leads there; homeUrl, which confusingly meant the repository's Latest view, is now latestUrl. /index.html still redirects to the latest builds, which is the page it always named.

The page. HomeViews.kt derives everything from what the API already serves. The rail reuses SystemMetricsView for its gauge rows, so a reconfigured metrics.disks changes the rail without a change here. The mosaic is RepoOverviewDto with one new field: recentBuilds, the duration and outcome of the last twelve builds (D1) — so one call of GET /api/repos feeds the whole page and GET /api/builds/durations, which is the History page's per-definition trend over thirty days, stays untouched.

The sparklines are the one thing the server does not draw. They are drawn by werkator.js from GET /api/system/history?range=24h, exactly as the system page's charts are, with the same drawSpark — which now takes its box from the element's own viewBox instead of assuming 300×120. Two implementations of one curve would be two things to keep in step; without JavaScript the rail still names every metric, its value and its link.

The privacy line (D8, D9). RepoRouting replaces three copies of repoOf and three copies of the privacy check. A named route of a private repository throws the same UnknownRepositoryException an unserved name throws, so the answer passes through the same handler with the same body. RepositoryPrivacy caches per repository and is refreshed for every served repository on every poll of /api/repos, so on a running server this path makes no forge request and the two 404s do not differ in their timing either.

Light and dark. The dark palette now stands twice in werkator.css — once for the browser's preference unless light was chosen, once for a chosen dark — because CSS cannot share a token list between a media query and a plain selector and this project has no frontend build. DarkModeTokensTest compares the two blocks, so a colour changed in one and forgotten in the other fails the build.

Open Questions

  • The switch has two states, light and dark, and no way back to "follow the browser" short of clearing site data. A third state would need a three-way control; the brief asked for a button.
  • A card's timestamp is the full YYYY-MM-DD HH:MM of UiFormats.timestamp, as on the Repositories page, where the draft showed a relative time ("2 h ago"). A relative format would need a second implementation in werkator.js to keep in step, and the invariant that the two produce identical formats is worth more than the shorter string.
  • The residual timing signal of Scenario#54.01 rests on the privacy cache being warm. It is, on any server whose pages are being polled; on a cold cache the first named request for a private repository is one forge round trip slower than for an unserved name.

Additional Changes

  • The rationale on GET /api/builds/durations was factually wrong: it claimed the build timings themselves were the secret, which its own neighbours (latest, branches, history) disprove by handing them out for every build. The empty list stays — a trend card is grouped by build definition, and with that name redacted every card carries the same label and the list says nothing — but for the real reason.
  • The D8 rule is now an invariant in AGENTS.md, so the next PR does not decide it differently again, and the architecture skill's sentence on durations was corrected with it.
  • Two sentences in the PR#53 doc were wrong: commit bbb892f reached main without any pull request at all, and the number 53 belongs to the documentation PR itself.
  • A repository's row, chip and card now lead to its Latest view (/latest, /repos/<name>/latest) instead of its "UI root". With one served repository that root was /, which since this PR is the home page — so the single card on it pointed at the page it sits on.
  • The rail's rows link to /system plainly. They were written with a #<key> fragment, but the system page's cards carry data-gauge, not matching ids, so the fragment would have been a dead anchor.
  • .grow needed min-width: 0, or the grid track grows to the sparkline's intrinsic width and pushes the value out of the rail on a phone.

Prerequisite PRs

  • PR#43 — the redaction of a private repository this PR narrows further.
  • PR#49 — the build-duration trend, whose endpoint this PR deliberately leaves alone.

Follow-up PRs

  • Graphs for Load, Swap, CPU wait and Mem wait on the system page, which the rail's tiles link to but which have no chart there yet.