UI: das Repository wird in der Seitenüberschrift per Drop-Down gewählt
Die Link-Leiste neben dem Menü entfällt. An ihrer Stelle steht dort, wo bisher der Repository-Name stand, eine Auswahlliste der bedienten Repositories; werkator.js hängt die aktuelle Ansicht (branches, history, current) an, damit der Wechsel auf derselben Seite bleibt. Mit nur einem Repository bleibt der Name wie bisher reiner Text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e61adfbbaa
commit
fe11a339ad
@@ -63,7 +63,7 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
|
|||||||
|
|
||||||
## Repository Context
|
## Repository Context
|
||||||
|
|
||||||
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean; the controllers no longer take it — they resolve per request from the `{repo}` path segment, and `registry.current()` is what the unscoped routes mean. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Routes, pages, and artifact files are repository-scoped (session D): every mapping exists twice, `/api/repos/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and show the switcher in the navigation.
|
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean; the controllers no longer take it — they resolve per request from the `{repo}` path segment, and `registry.current()` is what the unscoped routes mean. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Routes, pages, and artifact files are repository-scoped (session D): every mapping exists twice, `/api/repos/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and replace the repository name in the page title with a drop-down that switches repositories, keeping the current view.
|
||||||
|
|
||||||
## Build Execution
|
## Build Execution
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
|
|||||||
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
||||||
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
||||||
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
||||||
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and the navigation switches between them.
|
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and a drop-down in the page title switches between them.
|
||||||
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
||||||
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
||||||
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
|||||||
### D — Server, API, and UI scoping
|
### D — Server, API, and UI scoping
|
||||||
|
|
||||||
- ~~Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.~~ — done 2026-09-03 (PR #13): every route of the builds API, the pages, and the artifact files is mapped twice; the unscoped form is not an alias with an expiry date but the permanent way to say "the served repository", and an unknown name is a 404 in each controller's own shape.
|
- ~~Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.~~ — done 2026-09-03 (PR #13): every route of the builds API, the pages, and the artifact files is mapped twice; the unscoped form is not an alias with an expiry date but the permanent way to say "the served repository", and an unknown name is a 404 in each controller's own shape.
|
||||||
- ~~Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the navigation gains a **repository switcher** (a row's actions need the repository anyway, branches come from one origin, artifacts from one store — and with one repository a column is noise). Metrics page and control token stay instance-wide as planned.
|
- ~~Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the page title gains a **repository switcher** (a drop-down in place of the repository name, since 2026-09-03; first a link row beside the menu) (a row's actions need the repository anyway, branches come from one origin, artifacts from one store — and with one repository a column is noise). Metrics page and control token stay instance-wide as planned.
|
||||||
- ~~Gitea status links use the repo-scoped URLs~~ — done 2026-09-03: the permanent artifact links take the prefix (`BranchPermalinks.permanentUrl`; the key is a hash of the build name alone, so two repositories both having `main` would share one URL), and the **commit status now carries a target URL at all** — `server.publicBaseUrl` was documented as "used for all links posted to Gitea" while the executor posted `targetUrl = null`. It is the build's artifact page, repository-scoped.
|
- ~~Gitea status links use the repo-scoped URLs~~ — done 2026-09-03: the permanent artifact links take the prefix (`BranchPermalinks.permanentUrl`; the key is a hash of the build name alone, so two repositories both having `main` would share one URL), and the **commit status now carries a target URL at all** — `server.publicBaseUrl` was documented as "used for all links posted to Gitea" while the executor posted `targetUrl = null`. It is the build's artifact page, repository-scoped.
|
||||||
- Also done: `RunningBuild` carries its `RepoContext` (the carry-over from session C), so the current-builds views and the watcher's worktree pruning tell repositories apart, and `cancel` refuses a key that is not recorded in the named repository.
|
- Also done: `RunningBuild` carries its `RepoContext` (the carry-over from session C), so the current-builds views and the watcher's worktree pruning tell repositories apart, and `cancel` refuses a key that is not recorded in the named repository.
|
||||||
- ~~`docs/deployment.md` gets the registry setup~~ — done 2026-09-03: section "Serving Several Repositories" (clone, prepare, register, restart) with the name rules, the per-repository guard, and what the URLs look like with one repository and with several.
|
- ~~`docs/deployment.md` gets the registry setup~~ — done 2026-09-03: section "Serving Several Repositories" (clone, prepare, register, restart) with the name rules, the per-repository guard, and what the URLs look like with one repository and with several.
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
|
|||||||
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
|
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
|
||||||
h1 img { width: 32px; height: 32px; flex: none; }
|
h1 img { width: 32px; height: 32px; flex: none; }
|
||||||
h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; }
|
h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; }
|
||||||
|
/* the switcher takes the repository name's place and dresses like it: no box until hovered */
|
||||||
|
h1 .repo-select { align-self: flex-end; margin-bottom: 3px; padding: 2px 4px; border: 1px solid transparent; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--muted); font: inherit; font-size: 18px; font-weight: 400; cursor: pointer; }
|
||||||
|
h1 .repo-select:hover, h1 .repo-select:focus { border-color: var(--border); background: var(--panel); }
|
||||||
|
h1 .repo-select option { color: var(--text); background: var(--panel); }
|
||||||
h2 { margin: 20px 0 10px; font-size: 18px; }
|
h2 { margin: 20px 0 10px; font-size: 18px; }
|
||||||
.title-home { display: inline-flex; flex: none; }
|
.title-home { display: inline-flex; flex: none; }
|
||||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
||||||
@@ -163,6 +168,7 @@ th.num { font-family: inherit; font-size: 12px; }
|
|||||||
h1 .title-home { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
|
h1 .title-home { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
|
||||||
h1 .title-text { grid-column: 2; grid-row: 1; align-self: end; }
|
h1 .title-text { grid-column: 2; grid-row: 1; align-self: end; }
|
||||||
h1 .repo-name { grid-column: 2; grid-row: 2; align-self: start; font-size: 15px; overflow-wrap: anywhere; }
|
h1 .repo-name { grid-column: 2; grid-row: 2; align-self: start; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
h1 .repo-select { grid-column: 2; grid-row: 2; align-self: start; justify-self: start; margin: 0; padding: 0 4px; font-size: 15px; }
|
||||||
.view-row { flex-wrap: wrap; }
|
.view-row { flex-wrap: wrap; }
|
||||||
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
|
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
|
||||||
/* the live indicator collapses to a colored state dot so it cannot squeeze the menu;
|
/* the live indicator collapses to a colored state dot so it cannot squeeze the menu;
|
||||||
|
|||||||
@@ -736,12 +736,34 @@ function initReloadButton() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- repository switcher -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching the repository keeps the view: an option's value is the repository root
|
||||||
|
* (`/repos/<name>`), and the view this page shows is appended to it. A page that only
|
||||||
|
* exists for one repository's build — an artifact index — falls back to that
|
||||||
|
* repository's latest builds.
|
||||||
|
*/
|
||||||
|
function initRepoSelect() {
|
||||||
|
const select = document.getElementById("repo-select");
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const view = repoBase && path.startsWith(repoBase) ? path.slice(repoBase.length) : path;
|
||||||
|
const keptView = ["/branches", "/history", "/current"].includes(view) ? view : "";
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
window.location.href = select.value + keptView;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- page wiring ---------------------------------------------------------------
|
// ---- page wiring ---------------------------------------------------------------
|
||||||
|
|
||||||
initBuildsTable();
|
initBuildsTable();
|
||||||
initCurrentBuilds();
|
initCurrentBuilds();
|
||||||
initSystemTable();
|
initSystemTable();
|
||||||
initReloadButton();
|
initReloadButton();
|
||||||
|
initRepoSelect();
|
||||||
// pages with a poller update the banner from their own tick; the static ones ask once
|
// pages with a poller update the banner from their own tick; the static ones ask once
|
||||||
if (!refreshNow) {
|
if (!refreshNow) {
|
||||||
refreshWatcherBanner();
|
refreshWatcherBanner();
|
||||||
|
|||||||
@@ -16,7 +16,15 @@
|
|||||||
<h1 th:fragment="header(title)">
|
<h1 th:fragment="header(title)">
|
||||||
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
||||||
<span class="title-text" th:text="${title}">Latest Builds</span>
|
<span class="title-text" th:text="${title}">Latest Builds</span>
|
||||||
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
<!-- The repository switcher (ADR 0009): what makes several repositories one UI. It sits
|
||||||
|
where the repository name sits, because it *is* the repository name — the page's
|
||||||
|
subject, not a navigation target. Its option values are the repository roots;
|
||||||
|
werkator.js appends the current view, so switching keeps the reader on the same page.
|
||||||
|
With a single served repository there is nothing to switch, and the name is plain text. -->
|
||||||
|
<select th:if="${multiRepo}" id="repo-select" class="repo-select" aria-label="Repository">
|
||||||
|
<option th:each="r : ${repos}" th:value="${r.url}" th:selected="${r.current}" th:text="${r.name}">werkator</option>
|
||||||
|
</select>
|
||||||
|
<span class="repo-name" th:unless="${multiRepo} or ${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<th:block th:fragment="nav(view)">
|
<th:block th:fragment="nav(view)">
|
||||||
@@ -32,14 +40,6 @@
|
|||||||
<span th:if="${view == 'system'}">System</span>
|
<span th:if="${view == 'system'}">System</span>
|
||||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- The repository switcher (ADR 0009): what makes several repositories one UI.
|
|
||||||
With a single served repository there is nothing to switch, and the block is absent. -->
|
|
||||||
<nav class="repo-switch" th:if="${multiRepo}">
|
|
||||||
<th:block th:each="r : ${repos}">
|
|
||||||
<span th:if="${r.current}" th:text="${r.name}" class="repo-current">werkator</span>
|
|
||||||
<a th:unless="${r.current}" th:href="${r.url}" th:text="${r.name}">other</a>
|
|
||||||
</th:block>
|
|
||||||
</nav>
|
|
||||||
<span class="view-row-actions">
|
<span class="view-row-actions">
|
||||||
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
||||||
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view">⟳</button>
|
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view">⟳</button>
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(containsString("""href="/branches"""")))
|
.andExpect(content().string(containsString("""href="/branches"""")))
|
||||||
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
||||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
||||||
.andExpect(content().string(not(containsString("""class="repo-switch""""))))
|
.andExpect(content().string(not(containsString("""class="repo-select""""))))
|
||||||
|
.andExpect(content().string(containsString("""class="repo-name"""")))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("with several served repositories every link names its repository and the switcher appears") {
|
test("with several served repositories every link names its repository and the switcher appears") {
|
||||||
@@ -184,8 +185,10 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
||||||
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
||||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
||||||
.andExpect(content().string(containsString("""class="repo-switch"""")))
|
.andExpect(content().string(containsString("""class="repo-select"""")))
|
||||||
.andExpect(content().string(containsString("""href="/repos/other"""")))
|
.andExpect(content().string(containsString("""<option value="/repos/test" selected="selected">test</option>""")))
|
||||||
|
.andExpect(content().string(containsString("""<option value="/repos/other">other</option>""")))
|
||||||
|
.andExpect(content().string(not(containsString("""class="repo-name""""))))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a page of a repository this instance does not serve answers 404") {
|
test("a page of a repository this instance does not serve answers 404") {
|
||||||
|
|||||||
Reference in New Issue
Block a user