From 25a0742a58751ee563ac80d10b463324cf0cb5af Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 12:23:12 +0200 Subject: [PATCH] implemented 08-web-ui.md: added Thymeleaf-based web UI: templates for builds, artifacts, current builds, and fragments; static assets for favicon, CSS, and JavaScript --- CLAUDE.md | 8 +- build.gradle.kts | 11 + docs/bootstrapping.md | 7 + docs/configuration.md | 2 + docs/plan/07-server-mode.md | 2 +- docs/plan/08-web-ui.md | 27 ++ docs/plan/README.md | 2 +- .../hoennig/gittally/commands/InitCommand.kt | 2 + .../hoennig/gittally/config/GitTallyConfig.kt | 2 + .../gittally/server/BuildsApiController.kt | 10 +- .../hoennig/gittally/server/UiController.kt | 173 +++++++ .../de/hoennig/gittally/server/UiViews.kt | 98 ++++ src/main/resources/static/favicon.svg | 8 + src/main/resources/static/gittally.css | 147 ++++++ src/main/resources/static/gittally.js | 442 ++++++++++++++++++ src/main/resources/templates/artifact.html | 71 +++ src/main/resources/templates/builds.html | 76 +++ src/main/resources/templates/current.html | 40 ++ src/main/resources/templates/fragments.html | 43 ++ .../server/BuildsApiControllerTest.kt | 25 +- .../gittally/server/UiControllerTest.kt | 205 ++++++++ .../de/hoennig/gittally/server/UiViewsTest.kt | 35 ++ 22 files changed, 1420 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/gittally/server/UiController.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/UiViews.kt create mode 100644 src/main/resources/static/favicon.svg create mode 100644 src/main/resources/static/gittally.css create mode 100644 src/main/resources/static/gittally.js create mode 100644 src/main/resources/templates/artifact.html create mode 100644 src/main/resources/templates/builds.html create mode 100644 src/main/resources/templates/current.html create mode 100644 src/main/resources/templates/fragments.html create mode 100644 src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 9cdcbce..6aa783e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,11 @@ commands/ ConfigPrintCommand ← "config:print [--full]" ``` -The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API and artifact serving live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/gittally/control-token`. +The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API, artifact serving, and the web UI live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/gittally/control-token`. + +### Web UI + +The UI is server-rendered Thymeleaf (`UiController`, templates under `src/main/resources/templates/`) plus one hand-written JavaScript file (`static/gittally.js`) — no SPA framework, no frontend build pipeline. Pages render the full state server-side; the script then polls the JSON API and re-renders table bodies from data. Every fetch has a timeout and failures flip an explicit error badge — never re-fetch and diff whole HTML pages, and never leave a spinner without an error path (the legacy defect). Polling pauses while the tab is hidden. `UiFormats`/`gittally.js` must produce the same display formats (timestamps, durations). ### Configuration System @@ -68,7 +72,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat ### Package Structure -All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), and `server` (JSON API controllers, artifact serving, control token, watcher lifecycle). Tests mirror this structure under `src/test/kotlin`. +All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher lifecycle). Tests mirror this structure under `src/test/kotlin`. ## Testing Conventions diff --git a/build.gradle.kts b/build.gradle.kts index 159fe28..c2aa1e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework:spring-web") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("info.picocli:picocli-spring-boot-starter:4.7.6") @@ -52,6 +53,16 @@ dependencies { testImplementation("org.testcontainers:testcontainers") } +springBoot { + // exposes the project version to the UI footer via the BuildProperties bean; + // the volatile build time is excluded to keep builds repeatable + buildInfo { + properties { + excludes.set(setOf("time")) + } + } +} + kotlin { compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index 2ba6e66..105a0ac 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -125,3 +125,10 @@ Until then, a Java runtime must be available on the host. ```bash java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server ``` + +## Example: Self-Hosting GitTally + +[examples/setup-gittally-selfhost.sh](examples/setup-gittally-selfhost.sh) shows the full sequence as a runnable script: it sets up a GitTally instance that watches and builds GitTally itself. +Run it from a working checkout; it builds the JAR, creates a dedicated clone, runs `init`, writes the machine-specific config, and starts the server. +`INSTALL_DIR`, `ORIGIN_URL`, `SERVER_PORT`, `GIT_ACCOUNT`, and `GIT_TOKEN` can be overridden via environment variables. +The script also demonstrates the kick-start trick: resetting the local ref one commit behind origin makes the very first poll build immediately, instead of waiting for the next push. diff --git a/docs/configuration.md b/docs/configuration.md index abce42c..74e7912 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,6 +30,8 @@ server: port: 18080 # bind address of the `server` subcommand bindAddress: 0.0.0.0 + # optional Impressum (legal disclosure) link in the web UI footer; empty hides the link + impressumUrl: "" # Gitea integration for fetching commits and posting build statuses. gitea: diff --git a/docs/plan/07-server-mode.md b/docs/plan/07-server-mode.md index 4ee7c60..8abbcf7 100644 --- a/docs/plan/07-server-mode.md +++ b/docs/plan/07-server-mode.md @@ -57,7 +57,7 @@ Deviations and decisions: - The control token needs no config key. It is generated on first use and persisted to `.git/gittally/control-token` (mode 600); operators can write their own token there, deleting the file rotates it. Requests pass it via the `X-GitTally-Token` header or a `token` parameter; mismatch answers 403 like legacy. - `DELETE /api/builds/{artifactKey}` removes the result and then calls `ArtifactStore.prune(history)`, so no new store interface method was needed. - `GET /api/status/{commit}` also accepts abbreviated hashes (7–40 hex like legacy) and resolves them against the local history. The `GiteaClient` (step 03) gained 10s connect/read timeouts so the endpoint can never hang; a Gitea failure yields HTTP 200 with `status: unknown` (or the local status) plus `giteaError`. -- `POST /api/builds/{branch}/restart` rebuilds the branch's last recorded commit. Branch names containing `/` would need an encoded slash, which Tomcat rejects by default — revisit in step 08 if the UI needs restart for such branches. +- `POST /api/builds/{branch}/restart` rebuilds the branch's last recorded commit. Branch names containing `/` would need an encoded slash, which Tomcat rejects by default — revisit in step 08 if the UI needs restart for such branches. (Resolved in step 08: the endpoint moved to `POST /api/builds/restart?branch=…`.) - Spring Boot 4 moved `@WebMvcTest` into the new `spring-boot-starter-webmvc-test` test module (added as test dependency). - The server-profile `@SpringBootTest` mocks the `Watcher` bean, so booting the test never fetches origin or enqueues builds; watcher wiring is proven by verifying `start()` was called. diff --git a/docs/plan/08-web-ui.md b/docs/plan/08-web-ui.md index 6e2c85c..a7abd99 100644 --- a/docs/plan/08-web-ui.md +++ b/docs/plan/08-web-ui.md @@ -45,3 +45,30 @@ Robust live updates (the actual bug fix): - `./gradlew ktlintFormat` then `./gradlew build` is green. - Manual smoke test with a real build: status flips pending → running → success in the open browser tab without a manual reload, and a killed server results in error badges, not spinners. Document the result in this file. + +## Implementation Notes (2026-07-07) + +Implemented as designed: Thymeleaf templates (`fragments`, `builds`, `current`, `artifact`) rendered by `UiController`, one hand-written `static/gittally.js`, one `static/gittally.css` (loosely ported legacy look incl. dark mode and the mobile card layout), and the legacy favicon. +Pages render the full state server-side and work without JavaScript; the script polls the JSON API (tables 10s, current builds and log tails 3s) and re-renders table bodies from data. +Every fetch runs with an 8s timeout; a failure flips the nav-row indicator to an explicit `error` badge and dims the stale table — there is no loading state at all, so no spinner can get stuck. +Polling pauses on `visibilitychange` and refreshes immediately when the tab becomes visible; running durations tick client-side from `data-started-at`. + +Deviations and decisions: + +- The tables show one `Started` column instead of the legacy `Commit Time` + `Status Time` pair, and client-side column sorting was not ported; the API delivers newest-first. +- Status badges show the repository status; the per-row Gitea lookup (`/control/status` per commit) was deliberately not ported — that fan-out caused the legacy stuck spinners. `GET /api/status/{commit}` remains available. +- The control token is embedded as a `` tag in every rendered page (legacy embedded its cancel token in the cancel form the same way); `gittally.js` sends it via `X-GitTally-Token` for restart/cancel/delete. +- The restart endpoint moved from `POST /api/builds/{branch}/restart` to `POST /api/builds/restart?branch=…` so branch names with slashes work (resolves the step 07 deviation note). +- Artifact links render whenever a result has an artifact key; the artifact index page itself explains a pruned/missing artifact directory instead of a per-row existence check. +- `/builds/{artifactKey}` renders logs (top-level files) and the topmost `reports/**/index.html` pages from the artifact store; nested index pages below an already-listed one are skipped like legacy. Raw directory browsing is not offered. +- The build command shown on the artifact page is the currently configured one — the command effective at build time is not persisted. +- On `/current`, a build that leaves the running list keeps its card, marked `finished` with a link to its result page; its initial server render shows an empty log (the script fetches from offset 0). +- JS builds all DOM via `createElement`/`textContent`, so re-rendered data cannot inject markup; server-side escaping is covered by a MockMvc test with a hostile branch name. +- New config key `server.impressumUrl` (empty hides the footer link); the footer version comes from Spring Boot `buildInfo()` (`BuildProperties`, build time excluded for repeatability) with a `dev` fallback. +- `UiFormats` (Kotlin) and `gittally.js` intentionally produce the same timestamp/duration display formats. + +Manual smoke test (2026-07-07): scratch repository with a bare origin, `pollInterval: 5s`, and a 25s build command; server on port 18982, observed through a real browser tab. +After `git push`, the open Latest tab showed the new build without reload and its badge flipped `running` → `success` live (`pending` was too short to sample; the row itself appeared via polling). +`/current` showed the build card with streaming live log, ticking duration, and cancel button; on completion the card flipped to `finished` with a working link to the artifact page (status badge, build command, three log links, `reports/demo/index.html` served with no-cache headers). +Killing the server flipped the indicator to a red `error` badge (fetch failure in the tooltip) and dimmed the stale table — zero spinners (verified through a TCP proxy so the tab outlived the process); restarting the server brought `live` back. +The 375px viewport stacked rows as labeled cards, and the History view correctly offers delete but no restart. diff --git a/docs/plan/README.md b/docs/plan/README.md index cb052d0..4a85ff7 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -55,7 +55,7 @@ Core engine: Server and UI: - [x] `07-server-mode.md` — `server` subcommand, REST/JSON endpoints, artifact serving -- [ ] `08-web-ui.md` — HTML views with robust live updates +- [x] `08-web-ui.md` — HTML views with robust live updates - [ ] `09-system-metrics.md` — system resource monitoring page Completion: diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 2f8b6ec..f0b7dcb 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -108,6 +108,8 @@ class InitCommand( port: 18080 # bind address of the `server` subcommand bindAddress: 0.0.0.0 + # optional Impressum (legal disclosure) link in the web UI footer; empty hides the link + impressumUrl: "" # Gitea integration for fetching commits and posting build statuses. gitea: diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 245f22c..8259327 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -15,6 +15,8 @@ data class ServerConfig( /** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */ val port: Int = 18080, val bindAddress: String = "0.0.0.0", + /** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */ + val impressumUrl: String = "", ) data class GitConfig( diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index daa8c1c..150e053 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -67,10 +67,14 @@ class BuildsApiController( return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset)) } - /** Re-enqueues the branch's last recorded commit, like the legacy `/control/restart`. */ - @PostMapping("/api/builds/{branch}/restart") + /** + * Re-enqueues the branch's last recorded commit, like the legacy `/control/restart`. + * The branch is a parameter, not a path variable, because branch names may contain + * slashes (Tomcat rejects encoded slashes in the path by default). + */ + @PostMapping("/api/builds/restart") fun restart( - @PathVariable branch: String, + @RequestParam branch: String, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, @RequestParam(name = "token", required = false) paramToken: String?, ): ResponseEntity { diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt new file mode 100644 index 0000000..a564df0 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -0,0 +1,173 @@ +package de.hoennig.gittally.server + +import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.gittally.build.BuildExecutor +import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.gittally.build.BuildStatus +import de.hoennig.gittally.config.ConfigLoader +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.info.BuildProperties +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.server.ResponseStatusException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.name +import kotlin.streams.asSequence + +/** + * Server-rendered Thymeleaf views over the JSON API. The pages render the full + * state server-side (usable without JavaScript); `gittally.js` then polls the + * `/api/…` endpoints and re-renders the table bodies — pages are never re-fetched + * and diffed like legacy, so the UI cannot get stuck on a loading animation. + */ +@Controller +class UiController( + private val repository: BuildResultRepository, + private val buildExecutor: BuildExecutor, + private val artifactStore: ArtifactStore, + private val controlTokens: ControlTokenService, + private val configLoader: ConfigLoader, + private val buildProperties: ObjectProvider, +) { + var workingDir: Path = Paths.get(".") + + @GetMapping("/") + fun latest(model: Model): String { + val links = baseModel(model, view = "latest", pageTitle = "Latest Builds") + model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links) }) + model.addAttribute("apiPath", "/api/builds/latest") + model.addAttribute("allowRestart", true) + model.addAttribute("emptyMessage", "No builds recorded yet.") + return "builds" + } + + @GetMapping("/history") + fun history(model: Model): String { + val links = baseModel(model, view = "history", pageTitle = "Build History") + model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links) }) + model.addAttribute("apiPath", "/api/builds/history") + model.addAttribute("allowRestart", false) + model.addAttribute("emptyMessage", "No builds archived yet.") + return "builds" + } + + @GetMapping("/current") + fun current(model: Model): String { + val links = baseModel(model, view = "current", pageTitle = "Current Builds") + val results = repository.history() + val currentBuilds = + buildExecutor.currentBuilds().map { build -> + CurrentBuildView( + branch = build.branch, + commit = build.commit, + commitAbbrev = build.commit.take(12), + status = + (results.firstOrNull { it.artifactKey == build.artifactKey }?.status ?: BuildStatus.RUNNING) + .jsonName, + startedAtIso = build.startedAt.toString(), + startedAt = UiFormats.timestamp(build.startedAt), + artifactKey = build.artifactKey, + branchUrl = links.branchUrl(build.branch), + commitUrl = links.commitUrl(build.commit), + ) + } + model.addAttribute("currentBuilds", currentBuilds) + return "current" + } + + /** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */ + @GetMapping("/builds/{artifactKey}") + fun artifactIndex( + @PathVariable artifactKey: String, + model: Model, + ): String { + val result = repository.history().firstOrNull { it.artifactKey == artifactKey } + val artifactDir = artifactStore.artifactDir(artifactKey) + if (result == null && artifactDir == null) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'") + } + val links = baseModel(model, view = "artifact", pageTitle = "Build Artifacts") + model.addAttribute("artifactKey", artifactKey) + model.addAttribute("result", result?.let { BuildRowView.from(it, links) }) + model.addAttribute("hasArtifacts", artifactDir != null) + model.addAttribute("buildCommand", result?.let { branchBuildCommand(it.branch) }) + model.addAttribute("logs", artifactDir?.let { logFiles(it) } ?: emptyList()) + model.addAttribute("reportIndexes", artifactDir?.let { reportIndexes(it) } ?: emptyList()) + return "artifact" + } + + /** Adds the attributes every page needs and returns the Gitea link helper for row building. */ + private fun baseModel( + model: Model, + view: String, + pageTitle: String, + ): GiteaWebLinks { + val config = configLoader.load(workingDir) + val links = GiteaWebLinks(config.gitea) + val repoName = + listOf(config.gitea.owner.trim(), config.gitea.repo.trim()) + .filter { it.isNotEmpty() } + .joinToString("/") + model.addAttribute("view", view) + model.addAttribute("pageTitle", pageTitle) + model.addAttribute("repoName", repoName) + model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev") + model.addAttribute("impressumUrl", config.server.impressumUrl.trim()) + model.addAttribute("controlToken", controlTokens.token()) + model.addAttribute("giteaRepoUrl", links.repoUrl ?: "") + return links + } + + /** The currently configured build command — the command actually used at build time is not persisted. */ + private fun branchBuildCommand(branch: String): String { + val branches = configLoader.load(workingDir).branches + return (branches[branch] ?: branches["default"])?.buildCommand ?: "" + } + + /** The stored log files: all top-level regular files of the artifact directory. */ + private fun logFiles(artifactDir: Path): List = + Files.list(artifactDir).use { children -> + children + .asSequence() + .filter { Files.isRegularFile(it) } + .map { it.name } + .sorted() + .toList() + } + + /** + * The browsable `index.html` pages under `reports/`, shallowest first; pages nested + * below an already-listed report index are skipped — like the legacy artifact index. + */ + private fun reportIndexes(artifactDir: Path): List { + val reportsDir = artifactDir.resolve("reports") + if (!Files.isDirectory(reportsDir)) { + return emptyList() + } + val allIndexes = + Files.walk(reportsDir).use { paths -> + paths + .asSequence() + .filter { Files.isRegularFile(it) && it.name == "index.html" } + .map { reportsDir.relativize(it).toString() } + .sortedWith(compareBy({ path -> path.count { it == '/' } }, { it.length }, { it })) + .toList() + } + val knownDirs = mutableListOf() + val topmost = mutableListOf() + for (relativeIndex in allIndexes) { + val dir = relativeIndex.substringBeforeLast('/', "") + if (knownDirs.any { known -> known.isEmpty() || dir == known || dir.startsWith("$known/") }) { + continue + } + knownDirs += dir + topmost += relativeIndex + } + return topmost + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt new file mode 100644 index 0000000..a978f37 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -0,0 +1,98 @@ +package de.hoennig.gittally.server + +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.config.GiteaConfig +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Links into the Gitea web UI, like legacy `gitea_branch_web_url`; null when Gitea is not configured. */ +class GiteaWebLinks( + gitea: GiteaConfig, +) { + val repoUrl: String? = + listOf(gitea.baseUrl.trim().trimEnd('/'), gitea.owner.trim(), gitea.repo.trim()) + .takeIf { parts -> parts.all { it.isNotEmpty() } } + ?.let { (baseUrl, owner, repo) -> "$baseUrl/${escapePath(owner)}/${escapePath(repo)}" } + + fun branchUrl(branch: String): String? = repoUrl?.let { "$it/src/branch/${escapePath(branch)}" } + + fun commitUrl(commit: String): String? = repoUrl?.let { "$it/commit/${escapePath(commit)}" } + + /** Escapes each path segment but keeps `/` — branch names may contain slashes. */ + private fun escapePath(value: String): String = + value + .split('/') + .joinToString("/") { URLEncoder.encode(it, StandardCharsets.UTF_8).replace("+", "%20") } +} + +/** Display formatting shared by the server-rendered views; `gittally.js` renders the same formats. */ +object UiFormats { + private val timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()) + + fun timestamp(instant: Instant): String = timestampFormat.format(instant) + + /** `m:ss`, or `h:mm:ss` from one hour — like the legacy `MM:SS` duration column. */ + fun duration(duration: Duration?): String { + if (duration == null || duration.isNegative) { + return "" + } + val seconds = duration.seconds + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val rest = seconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, rest) + } else { + "%d:%02d".format(minutes, rest) + } + } +} + +/** One row of the latest/history build tables. */ +data class BuildRowView( + val branch: String, + val commit: String, + val commitAbbrev: String, + val status: String, + val startedAtIso: String, + val startedAt: String, + val duration: String, + val artifactKey: String, + val branchUrl: String?, + val commitUrl: String?, +) { + companion object { + fun from( + result: BuildResult, + links: GiteaWebLinks, + ) = BuildRowView( + branch = result.branch, + commit = result.commit, + commitAbbrev = result.commit.take(12), + status = result.status.jsonName, + startedAtIso = result.startedAt.toString(), + startedAt = UiFormats.timestamp(result.startedAt), + duration = UiFormats.duration(result.duration), + artifactKey = result.artifactKey, + branchUrl = links.branchUrl(result.branch), + commitUrl = links.commitUrl(result.commit), + ) + } +} + +/** One card of the current-builds view; the live log is fetched by `gittally.js`. */ +data class CurrentBuildView( + val branch: String, + val commit: String, + val commitAbbrev: String, + val status: String, + val startedAtIso: String, + val startedAt: String, + val artifactKey: String, + val branchUrl: String?, + val commitUrl: String?, +) diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..8f89dfc --- /dev/null +++ b/src/main/resources/static/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css new file mode 100644 index 0000000..b2ec67b --- /dev/null +++ b/src/main/resources/static/gittally.css @@ -0,0 +1,147 @@ +/* GitTally web UI — loosely ported from the legacy generated pages. */ + +:root { + color-scheme: light dark; + --bg: #f6f8fa; + --panel: #ffffff; + --text: #1f2937; + --muted: #6b7280; + --border: #d7dde5; + --row: #f9fafb; + --link: #155eef; + --success-bg: #dcfce7; + --success-text: #166534; + --failed-bg: #fee2e2; + --failed-text: #991b1b; + --running-bg: #dbeafe; + --running-text: #1d4ed8; + --pending-bg: #ede9fe; + --pending-text: #5b21b6; + --interrupted-bg: #ffedd5; + --interrupted-text: #9a3412; + --cancelled-bg: #e5e7eb; + --cancelled-text: #374151; + --unknown-bg: #f3f4f6; + --unknown-text: #4b5563; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111827; + --panel: #1f2937; + --text: #f3f4f6; + --muted: #9ca3af; + --border: #374151; + --row: #182235; + --link: #93c5fd; + --success-bg: #12351f; + --success-text: #86efac; + --failed-bg: #3f1717; + --failed-text: #fca5a5; + --running-bg: #112c55; + --running-text: #93c5fd; + --pending-bg: #2e1065; + --pending-text: #c4b5fd; + --interrupted-bg: #431f0b; + --interrupted-text: #fdba74; + --cancelled-bg: #374151; + --cancelled-text: #d1d5db; + --unknown-bg: #374151; + --unknown-text: #d1d5db; + } +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +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 img { width: 32px; height: 32px; flex: none; } +h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; } +h2 { margin: 20px 0 10px; font-size: 18px; } +.title-home { display: inline-flex; flex: none; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } +a { color: var(--link); font-weight: 650; text-decoration: none; } +a:hover { text-decoration: underline; } +.muted { color: var(--muted); } + +/* view toggle nav + live indicator */ +.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; } +.view-row-actions { margin-left: auto; } +.view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); } +.view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; } +.view-toggle span { background: var(--link); color: white; } +.view-toggle a { color: var(--link); } +.view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%, transparent); text-decoration: none; } + +/* build tables */ +.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); } +table { width: 100%; border-collapse: collapse; min-width: 900px; } +th, td { padding: 12px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } +th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } +tbody tr:nth-child(even) { background: var(--row); } +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: color-mix(in srgb, var(--link) 8%, transparent); } +tbody.is-stale { opacity: 0.55; } +.branch { font-weight: 650; } +.duration-cell { white-space: nowrap; } +.empty { padding: 28px 14px; color: var(--muted); text-align: center; } + +/* status badges */ +.status { display: inline-flex; align-items: center; min-width: 72px; justify-content: center; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; text-transform: uppercase; } +.status-success { background: var(--success-bg); color: var(--success-text); } +.status-failed, .status-error { background: var(--failed-bg); color: var(--failed-text); } +.status-running { background: var(--running-bg); color: var(--running-text); } +.status-pending { background: var(--pending-bg); color: var(--pending-text); } +.status-interrupted { background: var(--interrupted-bg); color: var(--interrupted-text); } +.status-cancelled { background: var(--cancelled-bg); color: var(--cancelled-text); } +.status-unknown, .status-finished { background: var(--unknown-bg); color: var(--unknown-text); } + +/* copy buttons and links with tools */ +.link-tools { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; } +.copy-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--muted); font: 14px/1 system-ui, sans-serif; cursor: pointer; } +.copy-button:hover { border-color: var(--border); background: color-mix(in srgb, var(--link) 8%, transparent); color: var(--link); } +.copy-button.is-copied { color: var(--success-text); } +.artifact-link { display: inline-flex; align-items: center; justify-content: center; } + +/* row actions */ +.actions-column, .actions-cell { width: 96px; min-width: 96px; } +.actions { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.action-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 30px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 18px/1 system-ui, sans-serif; cursor: pointer; } +.action-button:hover { background: color-mix(in srgb, var(--link) 8%, transparent); } +.action-button:disabled { color: var(--muted); cursor: default; } +.delete-button, .cancel-button { color: var(--failed-text); } +.cancel-button { font-size: 13px; font-weight: 700; } + +/* current-build cards */ +.empty-panel { padding: 28px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--muted); text-align: center; } +.build-card { margin: 0 0 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); overflow: hidden; } +.build-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.build-card-actions { margin-left: auto; } +.build-card-result { padding: 10px 14px; border-bottom: 1px solid var(--border); } +.live-log { margin: 0; padding: 12px 14px; max-height: 420px; overflow: auto; background: var(--bg); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } + +/* artifact index */ +.panel { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 8px 22px 22px; box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); } +.build-facts { list-style: none; margin: 0; padding: 0; } +.build-facts li { margin: 0 0 8px; } + +/* footer */ +.site-footer { width: min(1180px, calc(100% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; } + +/* small screens: stack table rows as cards, like legacy */ +@media (max-width: 680px) { + main { margin: 16px auto; } + h1 { font-size: 22px; } + .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; } + .table-wrap { overflow-x: visible; border: none; border-radius: 0; background: transparent; box-shadow: none; } + table, thead, tbody, tr, td { display: block; } + table { min-width: 0; } + thead { display: none; } + tbody { display: flex; flex-direction: column; gap: 12px; } + tbody tr { border: 1px solid var(--border); border-radius: 10px; background: var(--panel); overflow: hidden; box-shadow: 0 2px 8px rgb(15 23 42 / 0.07); } + tbody tr:nth-child(even) { background: var(--panel); } + td { display: flex; align-items: center; gap: 10px; padding: 10px 14px; } + td + td { border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); } + td[data-label]::before { content: attr(data-label); width: 90px; flex-shrink: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); } + .actions-cell { justify-content: center; background: color-mix(in srgb, var(--border) 18%, transparent); width: auto; } +} diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js new file mode 100644 index 0000000..76a2b75 --- /dev/null +++ b/src/main/resources/static/gittally.js @@ -0,0 +1,442 @@ +// GitTally web UI — polls the JSON API and re-renders table bodies from data. +// Every fetch has a timeout and failures render an explicit error badge, so the +// UI can never get stuck on a loading animation (the legacy defect). +"use strict"; + +// ---- pure helpers ---------------------------------------------------------- + +function formatDuration(totalSeconds) { + if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) { + return ""; + } + const seconds = Math.floor(totalSeconds); + const two = (n) => String(n).padStart(2, "0"); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = seconds % 60; + return hours > 0 ? `${hours}:${two(minutes)}:${two(rest)}` : `${minutes}:${two(rest)}`; +} + +function formatTimestamp(iso) { + if (!iso) { + return ""; + } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + const two = (n) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}` + + ` ${two(date.getHours())}:${two(date.getMinutes())}`; +} + +function abbrevCommit(commit) { + return (commit || "").slice(0, 12); +} + +const KNOWN_STATUSES = new Set( + ["success", "failed", "running", "pending", "interrupted", "cancelled", "unknown", "error", "finished"], +); + +function statusCssClass(status) { + return "status status-" + (KNOWN_STATUSES.has(status) ? status : "unknown"); +} + +// ---- shared infrastructure ------------------------------------------------- + +const FETCH_TIMEOUT_MS = 8000; +const TABLE_POLL_MS = 10000; +const CURRENT_POLL_MS = 3000; + +function metaContent(name) { + const element = document.querySelector(`meta[name="${name}"]`); + return element ? element.content : ""; +} + +const controlToken = metaContent("gittally-control-token"); +const giteaRepoUrl = metaContent("gittally-gitea-repo-url"); + +async function fetchJson(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + return response.json(); +} + +async function sendAction(url, method) { + const response = await fetch(url, { + method, + headers: { "X-GitTally-Token": controlToken }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } +} + +function setLiveIndicator(ok, detail) { + const indicator = document.getElementById("live-indicator"); + if (!indicator) { + return; + } + indicator.className = ok ? "status status-success" : "status status-error"; + indicator.textContent = ok ? "live" : "error"; + indicator.title = detail || ""; + document.querySelectorAll("tbody").forEach((tbody) => tbody.classList.toggle("is-stale", !ok)); +} + +// The page's refresh function; actions trigger it for an immediate update. +let refreshNow = null; + +/** Polls `refresh`; paused while the tab is hidden, refreshed immediately when visible again. */ +function startPolling(refresh, intervalMs) { + let timer = null; + const tick = () => { + refresh() + .then(() => setLiveIndicator(true, "last update " + formatTimestamp(new Date().toISOString()))) + .catch((error) => setLiveIndicator(false, String(error))); + }; + const start = () => { + if (timer === null) { + tick(); + timer = setInterval(tick, intervalMs); + } + }; + const stop = () => { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + }; + document.addEventListener("visibilitychange", () => (document.hidden ? stop() : start())); + refreshNow = tick; + start(); +} + +// ---- DOM building (textContent only — data can never inject markup) -------- + +function elem(tag, className, text) { + const element = document.createElement(tag); + if (className) { + element.className = className; + } + if (text != null) { + element.textContent = text; + } + return element; +} + +function externalLink(href, text) { + const anchor = elem("a", null, text); + anchor.href = href; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + return anchor; +} + +function copyButton(value, label) { + const button = elem("button", "copy-button", "⧉"); + button.type = "button"; + button.dataset.copy = value; + button.title = "Copy " + label; + button.setAttribute("aria-label", "Copy " + label); + return button; +} + +function statusBadge(status) { + return elem("span", statusCssClass(status), status); +} + +function actionButton(symbol, title, className, dataset) { + const button = elem("button", "action-button" + (className ? " " + className : ""), symbol); + button.type = "button"; + button.title = title; + button.setAttribute("aria-label", title); + Object.assign(button.dataset, dataset); + return button; +} + +// ---- latest/history table -------------------------------------------------- + +function renderBuildRow(build, allowRestart) { + const row = document.createElement("tr"); + row.dataset.artifactKey = build.artifactKey || ""; + row.dataset.branch = build.branch; + row.dataset.startedAt = build.startedAt || ""; + row.dataset.status = build.status || "unknown"; + + const statusCell = elem("td"); + statusCell.dataset.label = "Status"; + statusCell.appendChild(statusBadge(build.status || "unknown")); + row.appendChild(statusCell); + + const branchCell = elem("td", "branch"); + branchCell.dataset.label = "Branch"; + const branchTools = elem("span", "link-tools"); + branchTools.appendChild( + giteaRepoUrl + ? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch) + : elem("span", null, build.branch), + ); + branchTools.appendChild(copyButton(build.branch, "branch name")); + branchCell.appendChild(branchTools); + row.appendChild(branchCell); + + const commitCell = elem("td"); + commitCell.dataset.label = "Commit"; + const commitTools = elem("span", "link-tools"); + const commitCode = elem("code"); + commitCode.appendChild( + giteaRepoUrl + ? externalLink(giteaRepoUrl + "/commit/" + encodeURIComponent(build.commit), abbrevCommit(build.commit)) + : elem("span", null, abbrevCommit(build.commit)), + ); + commitTools.appendChild(commitCode); + commitTools.appendChild(copyButton(build.commit, "full commit ID")); + commitCell.appendChild(commitTools); + row.appendChild(commitCell); + + const startedCell = elem("td", null, formatTimestamp(build.startedAt)); + startedCell.dataset.label = "Started"; + row.appendChild(startedCell); + + const durationCell = elem("td", "duration-cell", formatDuration(build.durationSeconds)); + durationCell.dataset.label = "Duration"; + row.appendChild(durationCell); + + const artifactsCell = elem("td"); + artifactsCell.dataset.label = "Artifacts"; + if (build.artifactKey) { + const artifactLink = elem("a", "artifact-link", "📄"); + artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey); + artifactLink.title = "Open artifacts"; + artifactsCell.appendChild(artifactLink); + } else { + artifactsCell.textContent = "n/a"; + } + row.appendChild(artifactsCell); + + const actionsCell = elem("td", "actions-cell"); + const actions = elem("div", "actions"); + if (allowRestart) { + actions.appendChild(actionButton("↻", "Restart build", null, { action: "restart", branch: build.branch })); + } + if (build.artifactKey) { + actions.appendChild( + actionButton("×", "Delete stored build", "delete-button", { + action: "delete", + artifactKey: build.artifactKey, + }), + ); + } + actionsCell.appendChild(actions); + row.appendChild(actionsCell); + return row; +} + +function encodeBranchPath(branch) { + return (branch || "").split("/").map(encodeURIComponent).join("/"); +} + +function initBuildsTable() { + const table = document.getElementById("builds-table"); + if (!table) { + return; + } + const tbody = document.getElementById("build-rows"); + const allowRestart = table.dataset.allowRestart === "true"; + + async function refresh() { + const builds = await fetchJson(table.dataset.api); + tbody.replaceChildren(); + if (builds.length === 0) { + const cell = elem("td", "empty", table.dataset.emptyMessage || "No builds recorded yet."); + cell.colSpan = 7; + tbody.appendChild(elem("tr")).appendChild(cell); + return; + } + builds.forEach((build) => tbody.appendChild(renderBuildRow(build, allowRestart))); + } + + startPolling(refresh, TABLE_POLL_MS); +} + +// ---- current builds with live logs ------------------------------------------ + +function renderBuildCard(build) { + const card = elem("section", "build-card"); + card.dataset.artifactKey = build.artifactKey; + card.dataset.startedAt = build.startedAt || ""; + card.dataset.status = build.status || "running"; + + const header = elem("header", "build-card-header"); + header.appendChild(statusBadge(build.status || "running")); + const branchTools = elem("span", "branch link-tools"); + branchTools.appendChild( + giteaRepoUrl + ? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch) + : elem("span", null, build.branch), + ); + header.appendChild(branchTools); + const commitCode = elem("code"); + commitCode.appendChild( + giteaRepoUrl + ? externalLink(giteaRepoUrl + "/commit/" + encodeURIComponent(build.commit), abbrevCommit(build.commit)) + : elem("span", null, abbrevCommit(build.commit)), + ); + header.appendChild(commitCode); + header.appendChild(elem("span", "muted", "started " + formatTimestamp(build.startedAt))); + header.appendChild(elem("span", "duration-cell running-duration")); + const cardActions = elem("span", "build-card-actions"); + cardActions.appendChild( + actionButton("× Cancel", "Cancel build", "cancel-button", { + action: "cancel", + artifactKey: build.artifactKey, + }), + ); + header.appendChild(cardActions); + card.appendChild(header); + card.appendChild(elem("pre", "live-log")); + return card; +} + +/** A build that left the current list is finished — link to its result instead of dropping the card. */ +function markCardFinished(card) { + if (card.dataset.status === "finished") { + return; + } + card.dataset.status = "finished"; + const badge = card.querySelector(".status"); + badge.className = statusCssClass("finished"); + badge.textContent = "finished"; + card.querySelector(".cancel-button")?.remove(); + const resultNote = elem("p", "build-card-result"); + const resultLink = elem("a", null, "View result and artifacts"); + resultLink.href = "/builds/" + encodeURIComponent(card.dataset.artifactKey); + resultNote.appendChild(resultLink); + card.querySelector(".live-log").before(resultNote); +} + +function initCurrentBuilds() { + const container = document.getElementById("current-builds"); + if (!container) { + return; + } + const noCurrent = document.getElementById("no-current"); + const logOffsets = new Map(); + + function cardFor(artifactKey) { + return container.querySelector(`.build-card[data-artifact-key="${CSS.escape(artifactKey)}"]`); + } + + async function appendLogTail(build) { + const pre = cardFor(build.artifactKey)?.querySelector(".live-log"); + if (!pre) { + return; + } + const offset = logOffsets.get(build.artifactKey) || 0; + const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`; + const tail = await fetchJson(url); + logOffsets.set(build.artifactKey, tail.nextOffset); + if (tail.content) { + const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; + pre.append(tail.content); + if (nearBottom) { + pre.scrollTop = pre.scrollHeight; + } + } + } + + async function refresh() { + const builds = await fetchJson(container.dataset.api); + const activeKeys = new Set(builds.map((build) => build.artifactKey)); + builds.forEach((build) => { + let card = cardFor(build.artifactKey); + if (!card) { + card = container.appendChild(renderBuildCard(build)); + } else { + card.dataset.status = build.status; + const badge = card.querySelector(".status"); + badge.className = statusCssClass(build.status); + badge.textContent = build.status; + } + }); + container.querySelectorAll(".build-card").forEach((card) => { + if (!activeKeys.has(card.dataset.artifactKey)) { + markCardFinished(card); + } + }); + if (noCurrent) { + noCurrent.style.display = container.querySelector(".build-card") ? "none" : ""; + } + await Promise.all(builds.map(appendLogTail)); + } + + startPolling(refresh, CURRENT_POLL_MS); +} + +// ---- running-duration ticking ------------------------------------------------ + +function tickRunningDurations() { + const now = Date.now(); + document.querySelectorAll("[data-started-at]").forEach((element) => { + const status = element.dataset.status; + if (status !== "running" && status !== "pending") { + return; + } + const startedAt = new Date(element.dataset.startedAt).getTime(); + const durationCell = element.querySelector(".duration-cell"); + if (durationCell && !Number.isNaN(startedAt)) { + durationCell.textContent = formatDuration((now - startedAt) / 1000); + } + }); +} + +// ---- event delegation for copy and action buttons ----------------------------- + +document.addEventListener("click", (event) => { + const button = event.target.closest(".copy-button"); + if (!button || !navigator.clipboard) { + return; + } + navigator.clipboard.writeText(button.dataset.copy || "").then(() => { + button.classList.add("is-copied"); + setTimeout(() => button.classList.remove("is-copied"), 1200); + }); +}); + +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-action]"); + if (!button) { + return; + } + const action = button.dataset.action; + if (action === "delete" && !window.confirm("Delete this build result and its stored artifacts?")) { + return; + } + button.disabled = true; + try { + if (action === "restart") { + await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch), "POST"); + } else if (action === "cancel") { + await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST"); + } else if (action === "delete") { + await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE"); + } + if (refreshNow) { + refreshNow(); + } + } catch (error) { + setLiveIndicator(false, String(error)); + } finally { + button.disabled = false; + } +}); + +// ---- page wiring --------------------------------------------------------------- + +initBuildsTable(); +initCurrentBuilds(); +setInterval(tickRunningDurations, 1000); +tickRunningDurations(); diff --git a/src/main/resources/templates/artifact.html b/src/main/resources/templates/artifact.html new file mode 100644 index 0000000..2230b75 --- /dev/null +++ b/src/main/resources/templates/artifact.html @@ -0,0 +1,71 @@ + + + + +
+

+
+
+

Build

+
    +
  • + success +
  • +
  • Branch: + + main + main + + +
  • +
  • Commit: + + + 0123abc + 0123abc + + + +
  • +
  • Started: 2026-07-07 12:00 + — Duration: 1:23 +
  • +
  • Build command (as currently configured):
    + ./gradlew test +
  • +
+

+ This build has no stored result anymore — only its artifact files remain. +

+ +

Logs

+ +

+ No log files are stored for this build + — the build has not finished yet, or its artifacts were pruned. +

+ +

Build Artifacts

+ +

+ No artifact directories were produced by this build. +

+
+
+
+ + + diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html new file mode 100644 index 0000000..faf6b45 --- /dev/null +++ b/src/main/resources/templates/builds.html @@ -0,0 +1,76 @@ + + + + +
+

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusBranchCommitStartedDurationArtifactsActions
No builds recorded yet.
+ success + + + main + main + + + + + + 0123abc + 0123abc + + + + 2026-07-07 12:001:23 + 📄 + n/a + +
+ + +
+
+
+
+
+ + + diff --git a/src/main/resources/templates/current.html b/src/main/resources/templates/current.html new file mode 100644 index 0000000..3a4ce13 --- /dev/null +++ b/src/main/resources/templates/current.html @@ -0,0 +1,40 @@ + + + + +
+

+
+
+

+ No build is currently running. +

+
+
+ running + + main + main + + + 0123abc + 0123abc + + started 2026-07-07 12:00 + + + + +
+

+        
+
+
+
+ + + diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html new file mode 100644 index 0000000..1ad6a6c --- /dev/null +++ b/src/main/resources/templates/fragments.html @@ -0,0 +1,43 @@ + + + + + + GitTally + + + + + + + +

+ + Latest Builds + owner/repo +

+ +
+ + + static + +
+ + + + + diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt index d5b6fe6..a03e666 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -129,32 +129,39 @@ class BuildsApiControllerTest : FunSpec() { .andExpect(jsonPath("$.error").exists()) } - test("restart enqueues the branch's last recorded commit") { + test("restart enqueues the branch's last recorded commit, also for branch names with slashes") { val liveLogFile = tempDir.resolve("restart.log") - every { repository.latestFor("main") } returns successResult - every { buildExecutor.startBuild("main", successResult.commit) } returns runningBuild(liveLogFile) + every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic") + every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns + runningBuild(liveLogFile).copy(branch = "feature/topic") mockMvc - .perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "secret")) - .andExpect(status().isAccepted) + .perform( + post("/api/builds/restart") + .param("branch", "feature/topic") + .header(BuildsApiController.TOKEN_HEADER, "secret"), + ).andExpect(status().isAccepted) .andExpect(jsonPath("$.status").value("pending")) .andExpect(jsonPath("$.artifactKey").value("main-abc123-running")) - verify { buildExecutor.startBuild("main", successResult.commit) } + verify { buildExecutor.startBuild("feature/topic", successResult.commit) } } test("restart of a branch without recorded builds answers 404") { every { repository.latestFor("gone") } returns null mockMvc - .perform(post("/api/builds/gone/restart").param("token", "secret")) + .perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret")) .andExpect(status().isNotFound) } test("restart with a wrong token answers 403 and does not build") { mockMvc - .perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "wrong")) - .andExpect(status().isForbidden) + .perform( + post("/api/builds/restart") + .param("branch", "main") + .header(BuildsApiController.TOKEN_HEADER, "wrong"), + ).andExpect(status().isForbidden) verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt new file mode 100644 index 0000000..c166c4a --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -0,0 +1,205 @@ +package de.hoennig.gittally.server + +import com.ninjasquad.springmockk.MockkBean +import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.gittally.build.BuildExecutor +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.gittally.build.BuildStatus +import de.hoennig.gittally.build.RunningBuild +import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.gittally.config.GitTallyConfig +import de.hoennig.gittally.config.GiteaConfig +import de.hoennig.gittally.config.ServerConfig +import io.kotest.core.spec.style.FunSpec +import io.mockk.clearMocks +import io.mockk.every +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.not +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.time.Instant + +@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"]) +class UiControllerTest : FunSpec() { + private val tempDir: Path = Files.createTempDirectory("gittally-ui-test") + + @Autowired + lateinit var mockMvc: MockMvc + + @MockkBean + lateinit var repository: BuildResultRepository + + @MockkBean + lateinit var buildExecutor: BuildExecutor + + @MockkBean + lateinit var artifactStore: ArtifactStore + + @MockkBean + lateinit var controlTokens: ControlTokenService + + @MockkBean + lateinit var configLoader: ConfigLoader + + private val startedAt = Instant.parse("2026-07-07T10:00:00Z") + + private val successResult = + BuildResult( + branch = "main", + commit = "0123456789abcdef0123456789abcdef01234567", + status = BuildStatus.SUCCESS, + startedAt = startedAt, + duration = Duration.ofSeconds(83), + artifactKey = "main-abc123-key", + ) + + init { + beforeEach { + clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader) + every { configLoader.load(any()) } returns + GitTallyConfig( + server = ServerConfig(impressumUrl = "https://example.org/imprint"), + gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), + ) + every { controlTokens.token() } returns "test-token" + } + + test("latest view renders the empty state") { + every { repository.latestPerBranch() } returns emptyList() + + mockMvc + .perform(get("/")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("No builds recorded yet."))) + .andExpect(content().string(containsString("""data-api="/api/builds/latest""""))) + } + + test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") { + every { repository.latestPerBranch() } returns listOf(successResult) + + mockMvc + .perform(get("/")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("""status status-success"""))) + .andExpect(content().string(containsString("https://git.example.org/acme/widget/src/branch/main"))) + .andExpect( + content().string(containsString("https://git.example.org/acme/widget/commit/0123456789abcdef0123456789abcdef01234567")), + ).andExpect(content().string(containsString("/builds/main-abc123-key"))) + .andExpect(content().string(containsString("""data-action="restart""""))) + .andExpect(content().string(containsString("""data-action="delete""""))) + .andExpect(content().string(containsString("""name="gittally-control-token" content="test-token""""))) + .andExpect(content().string(containsString("https://example.org/imprint"))) + .andExpect(content().string(containsString("1:23"))) + } + + test("history view renders mixed history without restart actions") { + every { repository.history() } returns + listOf( + successResult.copy(branch = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key"), + successResult, + successResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"), + ) + + mockMvc + .perform(get("/history")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("status status-running"))) + .andExpect(content().string(containsString("status status-success"))) + .andExpect(content().string(containsString("status status-failed"))) + .andExpect(content().string(containsString("feature/x"))) + .andExpect(content().string(not(containsString("""data-action="restart"""")))) + } + + test("current view renders a card per running build with cancel button and started-at attribute") { + val build = + RunningBuild( + branch = "main", + commit = successResult.commit, + artifactKey = "main-abc123-running", + startedAt = startedAt, + stagingDir = tempDir, + liveLogFile = tempDir.resolve("build.log"), + ) + every { buildExecutor.currentBuilds() } returns listOf(build) + every { repository.history() } returns + listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = build.artifactKey, duration = null)) + + mockMvc + .perform(get("/current")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("build-card"))) + .andExpect(content().string(containsString("""data-started-at="2026-07-07T10:00:00Z""""))) + .andExpect(content().string(containsString("""data-action="cancel""""))) + .andExpect(content().string(containsString("status status-running"))) + } + + test("current view renders a clear no-build state") { + every { buildExecutor.currentBuilds() } returns emptyList() + every { repository.history() } returns emptyList() + + mockMvc + .perform(get("/current")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("No build is currently running."))) + } + + test("artifact index renders build command, log links, and topmost report index pages") { + val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) + Files.writeString(artifactDir.resolve("build.stdout.log"), "out") + Files.writeString(artifactDir.resolve("build.stderr.log"), "err") + Files.createDirectories(artifactDir.resolve("reports/tests/test")) + Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") + Files.createDirectories(artifactDir.resolve("reports/tests/test/packages")) + Files.writeString(artifactDir.resolve("reports/tests/test/packages/index.html"), "") + every { repository.history() } returns listOf(successResult) + every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir + + mockMvc + .perform(get("/builds/main-abc123-key")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("./gradlew --console=plain --no-daemon test"))) + .andExpect(content().string(containsString("/artifacts/main-abc123-key/build.stdout.log"))) + .andExpect(content().string(containsString("/artifacts/main-abc123-key/build.stderr.log"))) + .andExpect(content().string(containsString("reports/tests/test/index.html"))) + .andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) + } + + test("artifact index of a pruned build explains the missing artifacts") { + every { repository.history() } returns listOf(successResult) + every { artifactStore.artifactDir("main-abc123-key") } returns null + + mockMvc + .perform(get("/builds/main-abc123-key")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("No log files are stored for this build"))) + } + + test("artifact index of an unknown key answers 404") { + every { repository.history() } returns emptyList() + every { artifactStore.artifactDir("no-such-key") } returns null + + mockMvc + .perform(get("/builds/no-such-key")) + .andExpect(status().isNotFound) + } + + test("branch names with HTML metacharacters render escaped") { + val nasty = "feat/" + every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty)) + + mockMvc + .perform(get("/")) + .andExpect(status().isOk) + .andExpect(content().string(not(containsString("