werkator build succeeded after 01:31
Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #54
237 lines
15 KiB
Markdown
237 lines
15 KiB
Markdown
# RFC 0003: A Home Page for the Instance
|
||
|
||
**Status:**
|
||
- proposed: 2026-09-11
|
||
- accepted: 2026-09-11 (D1 to D9, see Decisions)
|
||
- rejected: -
|
||
- implemented: 2026-09-11 (PR#54)
|
||
|
||
**Proposal:** `/` serves a home page for the instance instead of the served repository's Latest view.
|
||
It shows what is running, what failed and how the machine is doing.
|
||
Every tile links to what it shows.
|
||
|
||
## Context and Problem
|
||
|
||
`/` showed Latest Builds of the served repository — one of six on a multi-repository instance.
|
||
The wordmark led to `/repos`, the repository list; with one served repository `/repos` redirected to `/`.
|
||
|
||
Three things were missing:
|
||
|
||
**No view of the instance.**
|
||
The repository list answers "which repositories exist and what happened last", not "what is happening now" or "what needs attention".
|
||
|
||
**The machine state is a separate page.**
|
||
Whether the host is healthy stands on `/system` only.
|
||
|
||
**The wordmark led to a subpage.**
|
||
`brandUrl` was either `/repos` or the served repository.
|
||
|
||
## The Design
|
||
|
||
Direction "Werkbank", one of three drafts.
|
||
The four artboards under [`0003-homepage/drafts/`](0003-homepage/drafts/) are authoritative:
|
||
`werkbank-desktop-light`, `werkbank-phone-light`, `werkbank-desktop-dark`, `werkbank-phone-dark` (each `.dc.html`).
|
||
|
||
Two changes against the desktop drafts:
|
||
|
||
- The footer spans the full content width below both columns, outside `.wrap` (D5).
|
||
`.site-footer` itself is unchanged, so no other page changes.
|
||
- Light and dark are both targets.
|
||
The dark draft differs from the light one only in the `:root` block, so no colour may appear in the markup — everything goes through the custom properties in `werkator.css`.
|
||
Dark mode already exists for every page; new is that the reader can choose it.
|
||
The browser stays the default, a button in the header overrules it.
|
||
See [Switchable Dark Mode](#switchable-dark-mode).
|
||
|
||
### Layout
|
||
|
||
Two columns: a narrow system rail left, the repository mosaic right.
|
||
|
||
```
|
||
header (siteHeader, but: brandUrl -> "/", nav gets "Home" as .current,
|
||
plus the new light/dark button)
|
||
.wrap → 288px | minmax(0,1fr), gap 24px, width as main (1180px)
|
||
aside.rail
|
||
h2 "System" + <em>last 24 h</em>
|
||
.verdict dot + "Healthy" + time
|
||
.glist one row per CPU, RAM and each monitored disk
|
||
(four on mih09: CPU, RAM, SSD, HDD)
|
||
.tgrid 2x2: Load (load1), Swap (swapUsedGib),
|
||
CPU wait (cpuPressure), Mem wait (memoryPressure)
|
||
.mhint "Open the System page →"
|
||
main
|
||
.head no visible h1; explanation line + "1 running · 1 failed"
|
||
.grid 3 columns, one card per repository
|
||
.legend successful / failed / running
|
||
footer
|
||
```
|
||
|
||
### The System Rail
|
||
|
||
The rail has two size classes, drawn along the same line as the system page.
|
||
|
||
What has a **gauge** there — CPU, RAM and one row per monitored disk — gets a row with label, sparkline (about 150 × 26) and value.
|
||
What is only an **info tile** there — Load, Swap, CPU wait, Mem wait — gets a smaller cell in the 2×2 grid with a sparkline (about 62 × 14).
|
||
Every row and cell links to `/system`.
|
||
|
||
The graphs are not meant to be read, but to invite a click.
|
||
They still show real data: anything else would be decoration that lies.
|
||
|
||
Out of scope, noted here because it surfaced while drafting:
|
||
Load, Swap, CPU wait and Mem wait have no graph on the system page at all, although `SystemHealth` judges by the pressure values.
|
||
That is a separate PR.
|
||
|
||
### The Repository Mosaic
|
||
|
||
One card per served repository, three columns, newest activity first — the same order as the repository list (D7).
|
||
What needs attention stands out by colour, not by position.
|
||
|
||
Each card carries: status dot and word, the name, tags (number of builds, running, failed), the newest build with branch and commit, a bar strip of the last builds, plus duration and start time.
|
||
|
||
The bar strip is the card's picture: twelve bars, height is duration, colour is outcome — accent for green, clay for red, translucent accent for running, line colour for "no build here".
|
||
It replaces no number; it makes a tendency visible.
|
||
|
||
Three card states: normal, `run` (accent tint), `bad` (clay tint).
|
||
A fourth, `ghost`, for a private repository: greyed, italic, not clickable, as in the repository list since PR#43.
|
||
It keeps its bar strip, unlabelled (D4) — the card is anonymous, not empty.
|
||
|
||
### Phone
|
||
|
||
On a narrow viewport (reference: 390 px, content 354 px) the rail sits above the cards:
|
||
the four gauge metrics in two columns, a hairline, the four tiles in two columns, then the link to the system page.
|
||
The cards stack below.
|
||
|
||
## Data Sources
|
||
|
||
Everything exists; no new endpoint is needed.
|
||
|
||
| What | From |
|
||
|------------------------------------------------------------|---------------------------------------------------------------------|
|
||
| Repositories, status, running/failed, newest build | `GET /api/repos` (`RepoOverviews` → `RepoOverviewDto`) |
|
||
| System verdict and current values | `GET /api/system` (`SystemMetricsCollector`, `SystemHealth.assess`) |
|
||
| History for the sparklines | `GET /api/system/history?range=24h` |
|
||
| Build durations for the bar strip | `GET /api/repos`, new field in `RepoOverviewDto` (see D1) |
|
||
|
||
The sparklines come from the minute values of the 24-hour history, condensed to a few points (maximum per bucket), normalised to the box, emitted as a `polyline` plus a closed `path` — the same technique as `.spark` on the system page, smaller.
|
||
Available series: `cpuUsed`, `load1`, `ramUsedGib`, `swapUsedGib`, `cpuPressure`, `memoryPressure`, `disk.<id>.usedGib`.
|
||
|
||
The page is rendered server-side and refreshed by `werkator.js` from the same endpoints, like every other page.
|
||
|
||
## Constraints
|
||
|
||
- Work happens in the worktree `~/Projekte/werkator-home` on branch `home-page`, never in the primary checkout.
|
||
- No pull request, no merge, no push to `main`; the feature branch only.
|
||
- No deployment to mih09 or vm4006 unless asked.
|
||
- Version **1.9.0** (new behaviour) with the matching entry at the top of `releases.html`, or `ReleaseVersionConsistencyTest` fails.
|
||
- PR-doc in `docs/prs/` per the [pr-doc skill](../../.claude/skills/pr-doc/SKILL.md), as PR#54.
|
||
- The `AGENTS.md` invariants hold: no emoji, icons as CSS masks, colours only through custom properties, `UiFormats` and `werkator.js` produce identical formats, every fetch has a timeout and a visible error state, no frontend build.
|
||
- Tests per the [writing-tests skill](../../.claude/skills/writing-tests/SKILL.md); `./gradlew ktlintFormat` before `./gradlew build`.
|
||
|
||
### Additional Changes
|
||
|
||
Two sentences in [`docs/prs/2026-09-11-PR#53-testcontainers-smoke-test-on-the-webspace.md`](../prs/2026-09-11-PR%2353-testcontainers-smoke-test-on-the-webspace.md) are wrong.
|
||
The header says PR#53 was squashed to main without its PR-doc.
|
||
Correct: commit `bbb892f` reached main without any pull request, and the number 53 belongs to the documentation PR itself (`07785a6`).
|
||
|
||
Plus the two consequences of D8: the rationale on the `durations` endpoint in `BuildsApiController`, and the invariant in `AGENTS.md`.
|
||
|
||
### On Questions
|
||
|
||
- Routine decisions are made here; the assumption goes into the PR-doc and is named in one sentence at the end.
|
||
- Blocking questions only where a wrong assumption would make the work worthless or trigger something irreversible.
|
||
- Everything else is collected and asked at a natural point: one point per question, multiple choice, recommendation first.
|
||
- If a question affects only one part, everything else is finished and what stayed open is stated.
|
||
- Where draft and code disagree, the code wins on invariants, otherwise the draft — and the spot is named.
|
||
|
||
## Decisions
|
||
|
||
Nine points were open while drafting and were decided on 2026-09-11.
|
||
|
||
**D1 — the bars come from `RepoOverviews`.**
|
||
`RepoOverviewDto` gets a field with duration and outcome of the repository's last twelve builds.
|
||
One call of `GET /api/repos` then feeds the whole mosaic, and the home page polls like every other page.
|
||
`GET /api/builds/durations` stays untouched: it is repository-scoped and aggregates per build definition over thirty days — the History page's trend, not the last twelve runs.
|
||
Price: `/api/repos` grows for the repository list too, which does not need the field.
|
||
|
||
**D2 — `/` becomes the home page, Latest moves to `/latest`.**
|
||
The brief asked for the home page without a further path segment.
|
||
The `AGENTS.md` invariant that the unscoped form permanently means the served repository survives: `/latest`, `/branches` and `/history` still do, only `/` belongs to the instance.
|
||
To do: `brandUrl` points at `/`, the repository pages' tab strip links Latest to `/latest`, and `/` is not redirected to Latest — nothing ever linked there stably.
|
||
|
||
**D3 — the home page applies with exactly one repository too.**
|
||
Every instance gets the same entrance; the system rail is the actual gain and does not depend on the number of repositories.
|
||
The mosaic is then one card and needs a layout rule so it does not look thin beside the rail — the card takes the whole width.
|
||
`/repos` still redirects to `/` with one repository, which now shows the home page instead of Latest.
|
||
|
||
**D4 — a private repository carries the strip, unlabelled.**
|
||
The grid stays visually even, and a visitor sees the same card as for any other repository, only without name, branch, commit and artifacts.
|
||
This extends what `Redaction.redact` already lets through — status, start time and duration of the newest build are visible today — from one build to twelve.
|
||
D8 says why this is not a breach.
|
||
|
||
**D5 — the footer is content-width and sits below both columns.**
|
||
It keeps the width of header and content (`min(1180px, 100% - 64px)`) but moves out of the mosaic, under the whole page.
|
||
`.site-footer` itself is unchanged, so the footer looks the same on every page.
|
||
|
||
**D6 — the watcher banner yes, the repository line no.**
|
||
If the server cannot reach origin, every card below is as old as the last successful poll, and a calm mosaic would claim otherwise — so the banner belongs here.
|
||
The repository line says exactly what the mosaic shows large, and would sit as a duplicate directly above itself.
|
||
Consequence: the home page does not use `viewHead` as a whole — no tabs, no `h1`, no repository line, but the banner.
|
||
|
||
**D7 — sorted by newest activity, not by urgency.**
|
||
The same order as the repository list: a repository is found where it last was.
|
||
Running and failed stand out by accent and clay tint rather than by position — otherwise the grid reorders under the mouse on every poll and the click misses.
|
||
Price: a repository that failed days ago can sit far down; the line "1 running · 1 failed" above the mosaic names it anyway.
|
||
|
||
**D8 — a private repository gives out its metadata, never its content.**
|
||
The line is written out and applies to every endpoint from here on.
|
||
|
||
*Content is secret*, and that expressly includes the names:
|
||
the repository's name, its branch and build-definition names, commits, artifacts and everything in them.
|
||
|
||
*Metadata may be given out* where it says nothing about the content:
|
||
that a build ran, when, how long, with what outcome — likewise storage sizes and every other aggregate figure of the machine.
|
||
The disk breakdown on the system page is therefore unproblematic: its slices are categories, not repositories.
|
||
|
||
The occasion was an apparent contradiction between D4 and PR#49, and checking resolved it the other way round.
|
||
`GET /api/builds/history` and `GET /api/builds/latest` redact name, branch, commit and artifact key for a private repository, but hand out `status`, `startedAt`, `runningSince` and `durationSeconds` for **every** build.
|
||
Exactly what the comment on `durations` calls the secret — how often it runs, when it runs, how big it is — has long been available from its neighbours, and for the whole history rather than twelve runs.
|
||
It is not D4 that is out of line, but PR#49's rationale.
|
||
|
||
That also settles the observation that `history` gives out the whole time series: that is intended, not tolerated.
|
||
|
||
Two consequences, both as additional changes:
|
||
`GET /api/builds/durations` **keeps** the empty list but gets the correct reason — a trend card is grouped by build definition, and without that name it is meaningless, not dangerous.
|
||
The rule itself belongs in `AGENTS.md` as an invariant, or the next PR decides it differently again.
|
||
|
||
**D9 — a private repository answers a visitor like a non-existent one.**
|
||
Writing out D8 showed that the name was redacted but confirmable:
|
||
`/api/repos/<private>/builds/latest` answered a visitor without the control token with 200 and redacted data, an invented name with 404.
|
||
Whoever guesses the name gets the confirmation — and for a repository named after a customer or an unreleased product, the name is the secret.
|
||
|
||
From here on all routes `/api/repos/<name>/…` and `/repos/<name>/…` answer **404** for a private repository without a control token, exactly as for an unknown name.
|
||
Redaction keeps the job it was meant for: filling the entries of repository list, repository line and mosaic without naming anything.
|
||
The redacted detail pages become unreachable for visitors — they were only reachable through a guessed name anyway, so nothing visible is lost.
|
||
|
||
Scope: one condition where `repoOf` resolves the name, in `UiController` as in the API controllers, plus scenarios in the PR-doc.
|
||
The answer must not differ from that for an unknown name in **anything** — same status, same body, and no early failure that betrays the repository's existence through response time.
|
||
|
||
## Switchable Dark Mode
|
||
|
||
Dark mode exists for every page, but only as `@media (prefers-color-scheme: dark)` in `werkator.css`: the browser decides, not the reader.
|
||
New is the switch, not the mode.
|
||
|
||
The browser stays the default; a button at the top right of the header — beside the live indicator, key and reload, as an icon mask like those — switches to light or dark and remembers the choice in the browser.
|
||
Technically the token blocks then stand three times: `:root` for light, `@media (prefers-color-scheme: dark)` for the default, and an attribute on the root element for the choice, which wins in both directions.
|
||
The stored choice must be applied before the first paint, or the page flickers.
|
||
|
||
`color-scheme` travels with the choice, or form controls and scrollbars stay with what the browser thinks right while the page shows the opposite.
|
||
|
||
The switch applies to the whole application, not only to the home page; it is the one part of this PR that touches every other page.
|
||
|
||
A naming trap: the model attribute `homeUrl` in `fragments.html` means the repository's Latest page today, not the instance home page.
|
||
It becomes `/latest` under D2 and should be renamed to what it is.
|
||
|
||
## Draft Sources
|
||
|
||
- [`0003-homepage/drafts/`](0003-homepage/drafts/) — the four accepted artboards
|
||
- [RFC 0001](0001-web-ui-instrument-panel.md) — the visual language ("Kin") the home page takes its tokens, status dots and icons from
|