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
@@ -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>