Files
werkator/src/main/kotlin/de/hoennig/gittally/server/UiController.kt
T
mhoennigandClaude Fable 5 56aa306481 Separate queue wait from build duration
BuildResult gains runningSince, set when a build leaves the queue; the
recorded duration now measures pure build time from that point, so build
runtimes can be tracked without queue wait. A build cancelled while still
queued records neither. The UI shows the live wait time in italics while
pending and switches to the real build time once the build runs; the Gitea
"after mm:ss" descriptions now also report pure build time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:55:36 +02:00

240 lines
10 KiB
Kotlin

package de.hoennig.gittally.server
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.config.ConfigLoader
import de.hoennig.gittally.metrics.SystemMetricsCollector
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 metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks,
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"
}
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
@GetMapping("/branches")
fun branches(model: Model): String {
val links = baseModel(model, view = "branches", pageTitle = "Branches")
model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", "/api/branches")
model.addAttribute("allowRestart", true)
model.addAttribute("emptyMessage", "No branches found on origin.")
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),
runningSinceIso = build.runningSince?.toString() ?: "",
artifactKey = build.artifactKey,
branchUrl = links.branchUrl(build.branch),
commitUrl = links.commitUrl(build.commit),
)
}
model.addAttribute("currentBuilds", currentBuilds)
return "current"
}
@GetMapping("/system")
fun system(model: Model): String {
baseModel(model, view = "system", pageTitle = "System Metrics")
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
return "system"
}
/** 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'")
}
return artifactIndexView(
model,
pageTitle = "Build Artifacts",
result = result,
artifactKey = artifactKey,
artifactDir = artifactDir,
filesBase = "/artifacts/$artifactKey",
)
}
/**
* The permanent artifact index of a branch's latest green build; the file links
* stay on the permanent `/branches/…` paths, so every link copied from this page
* outlives artifact pruning.
*/
@GetMapping("/branches/{branchKey}")
fun latestGreenArtifactIndex(
@PathVariable branchKey: String,
model: Model,
): String {
val build = branchPermalinks.latestGreenBuild(branchKey)
model.addAttribute("permanentBranch", build.branch)
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
return artifactIndexView(
model,
pageTitle = "Latest Green Build",
result = build,
artifactKey = build.artifactKey,
artifactDir = artifactStore.artifactDir(build.artifactKey),
filesBase = "/branches/$branchKey",
)
}
private fun artifactIndexView(
model: Model,
pageTitle: String,
result: BuildResult?,
artifactKey: String,
artifactDir: Path?,
filesBase: String,
): String {
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
model.addAttribute("artifactKey", artifactKey)
model.addAttribute("filesBase", filesBase)
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
}
}