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:
@@ -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?,
|
||||
)
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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("<script>")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user