werkator build succeeded after 01:31
Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #54
220 lines
14 KiB
Markdown
220 lines
14 KiB
Markdown
> **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.
|
||
|
||
## Related Links
|
||
|
||
- [RFC 0003: A Home Page for the Instance](../rfcs/0003-homepage.md) — the brief, with the nine decisions D1 to D9.
|
||
- [RFC 0001: Web UI Instrument Panel](../rfcs/0001-web-ui-instrument-panel.md) — the visual language the page borrows its tokens, status dots and icons from.
|
||
- The four accepted artboards under [`0003-homepage/drafts/`](../rfcs/0003-homepage/drafts/).
|
||
|
||
## 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
|
||
|
||
- [BuildsApiControllerTest: "a named private repository answers a visitor exactly like an unserved name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||
|
||
#### 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
|
||
|
||
- [BuildsApiControllerTest: "the unscoped route still serves the served repository, redacted"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||
|
||
#### 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
|
||
|
||
- [BuildsApiControllerTest: "the named route of a private repository opens for a browser holding the token"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||
|
||
### 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
|
||
|
||
- [RepoOverviewsTest: "the bar strip holds the last twelve builds, oldest first"](../../src/test/kotlin/de/hoennig/werkator/server/RepoOverviewsTest.kt)
|
||
|
||
#### 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
|
||
|
||
- [RepoOverviewsTest: "a private repository keeps its strip but loses everything that names it"](../../src/test/kotlin/de/hoennig/werkator/server/RepoOverviewsTest.kt)
|
||
|
||
### 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
|
||
|
||
- [UiControllerTest: "the home page shows the system rail and one card per repository"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||
|
||
#### 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
|
||
|
||
- [UiControllerTest: "a private repository is a card without a destination, strip and all"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||
|
||
#### 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
|
||
|
||
- [UiControllerTest: "with one served repository the home page still appears, its single card full width"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||
|
||
### 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
|
||
|
||
- [UiControllerTest: "every page carries the light/dark switch, applied before the first paint"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||
- [DarkModeTokensTest](../../src/test/kotlin/de/hoennig/werkator/server/DarkModeTokensTest.kt)
|
||
|
||
## 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.
|