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

This commit is contained in:
Michael Hoennig
2026-07-07 12:23:12 +02:00
parent 490914de0b
commit 25a0742a58
22 changed files with 1420 additions and 16 deletions
+6 -2
View File
@@ -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
+11
View File
@@ -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")
+7
View File
@@ -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.
+2
View File
@@ -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:
+1 -1
View File
@@ -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 (740 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.
+27
View File
@@ -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 `<meta>` 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.
+1 -1
View File
@@ -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:
@@ -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:
@@ -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(
@@ -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<Any> {
@@ -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<BuildProperties>,
) {
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<String>())
model.addAttribute("reportIndexes", artifactDir?.let { reportIndexes(it) } ?: emptyList<String>())
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<String> =
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<String> {
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<String>()
val topmost = mutableListOf<String>()
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
}
}
@@ -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?,
)
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GitTally">
<rect width="64" height="64" rx="14" fill="#155eef"/>
<path d="M17 47V18m0 14h13c7 0 10-4 10-11" fill="none" stroke="#f9fafb" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="17" cy="18" r="5" fill="#DD4901"/>
<circle cx="17" cy="47" r="5" fill="#DD4901"/>
<circle cx="40" cy="21" r="5" fill="#DD4901"/>
<path d="M44 35v14M51 35v14M58 35v14M43 47h16" fill="none" stroke="#f9fafb" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

+147
View File
@@ -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; }
}
+442
View File
@@ -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();
@@ -0,0 +1,71 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head th:replace="~{fragments :: head(${pageTitle})}"></head>
<body>
<main>
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
<div th:replace="~{fragments :: nav(${view})}"></div>
<article class="panel">
<h2>Build</h2>
<ul th:if="${result != null}" class="build-facts">
<li>
<span th:class="'status status-' + ${result.status}" th:text="${result.status}">success</span>
</li>
<li>Branch:
<span class="branch link-tools">
<a th:if="${result.branchUrl != null}" th:href="${result.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${result.branch}">main</a>
<span th:if="${result.branchUrl == null}" th:text="${result.branch}">main</span>
<button class="copy-button" type="button" th:attr="data-copy=${result.branch}"
title="Copy branch name" aria-label="Copy branch name">⧉</button>
</span>
</li>
<li>Commit:
<span class="link-tools">
<code>
<a th:if="${result.commitUrl != null}" th:href="${result.commitUrl}" target="_blank"
rel="noopener noreferrer" th:text="${result.commitAbbrev}">0123abc</a>
<span th:if="${result.commitUrl == null}" th:text="${result.commitAbbrev}">0123abc</span>
</code>
<button class="copy-button" type="button" th:attr="data-copy=${result.commit}"
title="Copy full commit ID" aria-label="Copy full commit ID">⧉</button>
</span>
</li>
<li>Started: <span th:text="${result.startedAt}">2026-07-07 12:00</span>
<th:block th:if="${result.duration != ''}">— Duration: <span th:text="${result.duration}">1:23</span></th:block>
</li>
<li th:if="${buildCommand != null}">Build command (as currently configured):<br>
<code th:text="${buildCommand}">./gradlew test</code>
</li>
</ul>
<p th:if="${result == null}" class="muted">
This build has no stored result anymore — only its artifact files remain.
</p>
<h2>Logs</h2>
<ul th:if="${!#lists.isEmpty(logs)}">
<li th:each="log : ${logs}">
<a th:href="'/artifacts/' + ${artifactKey} + '/' + ${log}" th:text="${log}">build.log</a>
</li>
</ul>
<p th:if="${#lists.isEmpty(logs)}" class="muted">
No log files are stored for this build<th:block th:unless="${hasArtifacts}">
— the build has not finished yet, or its artifacts were pruned</th:block>.
</p>
<h2>Build Artifacts</h2>
<ul th:if="${!#lists.isEmpty(reportIndexes)}">
<li th:each="report : ${reportIndexes}">
<a th:href="'/artifacts/' + ${artifactKey} + '/reports/' + ${report}"
th:text="'reports/' + ${report}">reports/tests/index.html</a>
</li>
</ul>
<p th:if="${#lists.isEmpty(reportIndexes)}" class="muted">
No artifact directories were produced by this build.
</p>
</article>
</main>
<footer th:replace="~{fragments :: footer}"></footer>
<script src="/gittally.js"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head th:replace="~{fragments :: head(${pageTitle})}"></head>
<body>
<main>
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
<div th:replace="~{fragments :: nav(${view})}"></div>
<div class="table-wrap">
<table id="builds-table"
th:attr="data-api=${apiPath},data-allow-restart=${allowRestart},data-empty-message=${emptyMessage}">
<thead>
<tr>
<th>Status</th>
<th>Branch</th>
<th>Commit</th>
<th>Started</th>
<th>Duration</th>
<th>Artifacts</th>
<th class="actions-column">Actions</th>
</tr>
</thead>
<tbody id="build-rows">
<tr th:if="${#lists.isEmpty(rows)}">
<td class="empty" colspan="7" th:text="${emptyMessage}">No builds recorded yet.</td>
</tr>
<tr th:each="row : ${rows}"
th:attr="data-artifact-key=${row.artifactKey},data-branch=${row.branch},data-started-at=${row.startedAtIso},data-status=${row.status}">
<td data-label="Status">
<span th:class="'status status-' + ${row.status}" th:text="${row.status}">success</span>
</td>
<td class="branch" data-label="Branch">
<span class="link-tools">
<a th:if="${row.branchUrl != null}" th:href="${row.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${row.branch}">main</a>
<span th:if="${row.branchUrl == null}" th:text="${row.branch}">main</span>
<button class="copy-button" type="button" th:attr="data-copy=${row.branch}"
title="Copy branch name" aria-label="Copy branch name">⧉</button>
</span>
</td>
<td data-label="Commit">
<span class="link-tools">
<code>
<a th:if="${row.commitUrl != null}" th:href="${row.commitUrl}" target="_blank"
rel="noopener noreferrer" th:text="${row.commitAbbrev}">0123abc</a>
<span th:if="${row.commitUrl == null}" th:text="${row.commitAbbrev}">0123abc</span>
</code>
<button class="copy-button" type="button" th:attr="data-copy=${row.commit}"
title="Copy full commit ID" aria-label="Copy full commit ID">⧉</button>
</span>
</td>
<td data-label="Started" th:text="${row.startedAt}">2026-07-07 12:00</td>
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
<td data-label="Artifacts">
<a th:if="${row.artifactKey != ''}" class="artifact-link"
th:href="'/builds/' + ${row.artifactKey}" title="Open artifacts">📄</a>
<span th:if="${row.artifactKey == ''}">n/a</span>
</td>
<td class="actions-cell">
<div class="actions">
<button th:if="${allowRestart}" class="action-button" type="button" data-action="restart"
th:attr="data-branch=${row.branch}" title="Restart build"
aria-label="Restart build">↻</button>
<button th:if="${row.artifactKey != ''}" class="action-button delete-button" type="button"
data-action="delete" th:attr="data-artifact-key=${row.artifactKey}"
title="Delete stored build" aria-label="Delete stored build">×</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</main>
<footer th:replace="~{fragments :: footer}"></footer>
<script src="/gittally.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head th:replace="~{fragments :: head(${pageTitle})}"></head>
<body>
<main>
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
<div th:replace="~{fragments :: nav(${view})}"></div>
<div id="current-builds" data-api="/api/builds/current">
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
No build is currently running.
</p>
<section class="build-card" th:each="build : ${currentBuilds}"
th:attr="data-artifact-key=${build.artifactKey},data-started-at=${build.startedAtIso},data-status=${build.status}">
<header class="build-card-header">
<span th:class="'status status-' + ${build.status}" th:text="${build.status}">running</span>
<span class="branch link-tools">
<a th:if="${build.branchUrl != null}" th:href="${build.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${build.branch}">main</a>
<span th:if="${build.branchUrl == null}" th:text="${build.branch}">main</span>
</span>
<code>
<a th:if="${build.commitUrl != null}" th:href="${build.commitUrl}" target="_blank"
rel="noopener noreferrer" th:text="${build.commitAbbrev}">0123abc</a>
<span th:if="${build.commitUrl == null}" th:text="${build.commitAbbrev}">0123abc</span>
</code>
<span class="muted" th:text="'started ' + ${build.startedAt}">started 2026-07-07 12:00</span>
<span class="duration-cell running-duration"></span>
<span class="build-card-actions">
<button class="action-button cancel-button" type="button" data-action="cancel"
th:attr="data-artifact-key=${build.artifactKey}" title="Cancel build">× Cancel</button>
</span>
</header>
<pre class="live-log" th:attr="aria-label='live log of ' + ${build.branch}"></pre>
</section>
</div>
</main>
<footer th:replace="~{fragments :: footer}"></footer>
<script src="/gittally.js"></script>
</body>
</html>
@@ -0,0 +1,43 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head th:fragment="head(title)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${#strings.isEmpty(repoName)} ? ${title} + ' — GitTally' : ${title} + ' — ' + ${repoName} + ' — GitTally'">GitTally</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/gittally.css">
<meta name="gittally-control-token" th:content="${controlToken}">
<meta name="gittally-gitea-repo-url" th:content="${giteaRepoUrl}">
</head>
<body>
<h1 th:fragment="header(title)">
<a class="title-home" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
<span th:text="${title}">Latest Builds</span>
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
</h1>
<div th:fragment="nav(view)" class="view-row">
<nav class="view-toggle">
<span th:if="${view == 'latest'}">Latest</span>
<a th:unless="${view == 'latest'}" href="/">Latest</a>
<span th:if="${view == 'history'}">History</span>
<a th:unless="${view == 'history'}" href="/history">History</a>
<span th:if="${view == 'current'}">Current</span>
<a th:unless="${view == 'current'}" href="/current">Current</a>
</nav>
<span class="view-row-actions">
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
</span>
</div>
<footer th:fragment="footer" class="site-footer">
<strong><em th:text="'GitTally v' + ${version}">GitTally</em></strong>
— © <a href="https://michael.hoennig.de" target="_blank" rel="noopener noreferrer">Michael Hönnig</a>, 2026
<th:block th:unless="${#strings.isEmpty(impressumUrl)}">
<a th:href="${impressumUrl}" target="_blank" rel="noopener noreferrer">Impressum (Legal Disclosure)</a>
</th:block>
</footer>
</body>
</html>
@@ -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()) }
}
@@ -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"), "<html></html>")
Files.createDirectories(artifactDir.resolve("reports/tests/test/packages"))
Files.writeString(artifactDir.resolve("reports/tests/test/packages/index.html"), "<html></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/<script>alert('x')</script>"
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.andExpect(content().string(not(containsString("<script>alert"))))
.andExpect(content().string(containsString("&lt;script&gt;")))
}
}
}
@@ -0,0 +1,35 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.config.GiteaConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Duration
class UiViewsTest : FunSpec() {
init {
test("durations format as m:ss and h:mm:ss like the legacy duration column") {
UiFormats.duration(null) shouldBe ""
UiFormats.duration(Duration.ofSeconds(0)) shouldBe "0:00"
UiFormats.duration(Duration.ofSeconds(83)) shouldBe "1:23"
UiFormats.duration(Duration.ofSeconds(3600 + 62)) shouldBe "1:01:02"
}
test("Gitea web links escape branch segments but keep slashes") {
val links =
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
links.repoUrl shouldBe "https://git.example.org/acme/widget"
links.branchUrl("feature/some topic") shouldBe
"https://git.example.org/acme/widget/src/branch/feature/some%20topic"
links.commitUrl("0123abc") shouldBe "https://git.example.org/acme/widget/commit/0123abc"
}
test("Gitea web links are null when Gitea is not configured") {
val links = GiteaWebLinks(GiteaConfig())
links.repoUrl shouldBe null
links.branchUrl("main") shouldBe null
links.commitUrl("0123abc") shouldBe null
}
}
}