diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt index 1cf9898..b53db36 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -120,6 +120,7 @@ class BuildExecutor( return } build.running = true + build.runningBuild.runningSince = Instant.now() transition(build, BuildStatus.RUNNING, duration = null) val preparedWorkspace = workspaces.prepare( @@ -140,7 +141,8 @@ class BuildExecutor( log.error("build of branch {} crashed", build.runningBuild.branch, e) appendToLiveLog(build, "\nbuild crashed: ${e.message}\n") } finally { - val duration = Duration.between(build.runningBuild.startedAt, Instant.now()) + // pure build time, without the queue wait; null when the build never started executing + val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) } val result = transition(build, finalStatus, duration) try { artifactStore.persist(result, build.runningBuild.stagingDir, workspace) @@ -277,12 +279,17 @@ class BuildExecutor( val runningBuild = build.runningBuild val updated = repository.updateByArtifactKey(runningBuild.artifactKey) { - it.copy(status = status, duration = duration ?: it.duration) + it.copy( + status = status, + runningSince = runningBuild.runningSince ?: it.runningSince, + duration = duration ?: it.duration, + ) } ?: BuildResult( branch = runningBuild.branch, commit = runningBuild.commit, status = status, startedAt = runningBuild.startedAt, + runningSince = runningBuild.runningSince, duration = duration, artifactKey = runningBuild.artifactKey, ).also { repository.append(it) } diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt index 5ae8612..02aa77e 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt @@ -7,7 +7,11 @@ data class BuildResult( val branch: String, val commit: String, val status: BuildStatus, + /** When the build was accepted (enqueued); the time until [runningSince] is queue wait. */ val startedAt: Instant, + /** When the build actually started executing; null while queued or when cancelled in the queue. */ + val runningSince: Instant? = null, + /** Pure build execution time (from [runningSince]), without the queue wait. */ val duration: Duration? = null, val artifactKey: String, ) diff --git a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt index 9f539e8..92df23a 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt @@ -13,7 +13,11 @@ data class RunningBuild( val stagingDir: Path, /** Combined stdout+stderr log, written live while the build runs. */ val liveLogFile: Path, -) +) { + /** Set by the executor when the build leaves the queue and starts executing. */ + @Volatile + var runningSince: Instant? = null +} /** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */ data class BuildStatusChangedEvent( diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index 209c3c3..be9b71b 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -13,6 +13,8 @@ data class BuildResultDto( val commit: String, val status: String, val startedAt: Instant, + /** When the build left the queue; the UI derives the live build time from this, the wait time from [startedAt]. */ + val runningSince: Instant? = null, val durationSeconds: Long?, val artifactKey: String, ) { @@ -23,6 +25,7 @@ data class BuildResultDto( commit = result.commit, status = result.status.jsonName, startedAt = result.startedAt, + runningSince = result.runningSince, durationSeconds = result.duration?.seconds, artifactKey = result.artifactKey, ) @@ -40,6 +43,7 @@ data class BranchDto( val commit: String, val status: String, val startedAt: Instant?, + val runningSince: Instant? = null, val durationSeconds: Long?, val artifactKey: String, val latestGreenUrl: String? = null, @@ -65,6 +69,7 @@ data class BranchDto( commit = latest.commit, status = latest.status.jsonName, startedAt = latest.startedAt, + runningSince = latest.runningSince, durationSeconds = latest.duration?.seconds, artifactKey = latest.artifactKey, latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null, @@ -80,6 +85,7 @@ data class CurrentBuildDto( val artifactKey: String, val status: String, val startedAt: Instant, + val runningSince: Instant? = null, val logSize: Long, ) diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index 491983d..aeff035 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -60,6 +60,7 @@ class BuildsApiController( (results.firstOrNull { it.artifactKey == build.artifactKey }?.status ?: BuildStatus.RUNNING) .jsonName, startedAt = build.startedAt, + runningSince = build.runningSince, logSize = liveLogSize(build.liveLogFile), ) } diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index 94e4a7f..fb67a54 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -87,6 +87,7 @@ class UiController( .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), diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 3d7f347..d879937 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -75,6 +75,8 @@ data class BuildRowView( val status: String, val startedAtIso: String, val startedAt: String, + /** ISO timestamp of the run start for the live build-time ticker; empty while queued. */ + val runningSinceIso: String, val duration: String, val artifactKey: String, val branchUrl: String?, @@ -94,6 +96,7 @@ data class BuildRowView( status = result.status.jsonName, startedAtIso = result.startedAt.toString(), startedAt = UiFormats.timestamp(result.startedAt), + runningSinceIso = result.runningSince?.toString() ?: "", duration = UiFormats.duration(result.duration), artifactKey = result.artifactKey, branchUrl = links.branchUrl(result.branch), @@ -112,6 +115,7 @@ data class BuildRowView( status = entry.status, startedAtIso = entry.startedAt?.toString() ?: "", startedAt = entry.startedAt?.let { UiFormats.timestamp(it) } ?: "", + runningSinceIso = entry.runningSince?.toString() ?: "", duration = UiFormats.duration(entry.durationSeconds?.let { Duration.ofSeconds(it) }), artifactKey = entry.artifactKey, branchUrl = links.branchUrl(entry.branch), @@ -130,6 +134,7 @@ data class CurrentBuildView( val status: String, val startedAtIso: String, val startedAt: String, + val runningSinceIso: String, val artifactKey: String, val branchUrl: String?, val commitUrl: String?, diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css index e2348cf..e29a71d 100644 --- a/src/main/resources/static/gittally.css +++ b/src/main/resources/static/gittally.css @@ -88,6 +88,8 @@ 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; } +/* queue wait time of a pending build — italic to distinguish it from real build time */ +.duration-wait { font-style: italic; color: var(--muted); } .empty { padding: 28px 14px; color: var(--muted); text-align: center; } /* system metrics */ diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index 72da677..05c988f 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -63,15 +63,24 @@ function elapsedSeconds(startedAtIso) { } /** - * The duration to display for a build: the recorded duration once finished, the - * live elapsed time while running or pending — so re-rendered rows never show an - * empty cell that the once-per-second ticker fills back in (visible flicker). + * The duration to display for a build, computed at render time so re-rendered + * rows never show an empty cell that the ticker fills back in (visible flicker): + * the recorded build time once finished, the live build time (since the build + * left the queue) while running, and the wait time while pending — the latter + * is styled italic via `duration-wait` to distinguish it from build time. */ function displayDurationSeconds(build) { - if (build.durationSeconds != null) { - return build.durationSeconds; + if (build.status === "running") { + return elapsedSeconds(build.runningSince || build.startedAt); } - return build.status === "running" || build.status === "pending" ? elapsedSeconds(build.startedAt) : null; + if (build.status === "pending") { + return elapsedSeconds(build.startedAt); + } + return build.durationSeconds; +} + +function durationCellClass(status) { + return "duration-cell" + (status === "pending" ? " duration-wait" : ""); } // ---- shared infrastructure ------------------------------------------------- @@ -197,6 +206,7 @@ function renderBuildRow(build, allowRestart) { row.dataset.artifactKey = build.artifactKey || ""; row.dataset.branch = build.branch; row.dataset.startedAt = build.startedAt || ""; + row.dataset.runningSince = build.runningSince || ""; row.dataset.status = build.status || "unknown"; const statusCell = elem("td"); @@ -234,7 +244,7 @@ function renderBuildRow(build, allowRestart) { startedCell.dataset.label = "Started"; row.appendChild(startedCell); - const durationCell = elem("td", "duration-cell", formatDuration(displayDurationSeconds(build))); + const durationCell = elem("td", durationCellClass(build.status), formatDuration(displayDurationSeconds(build))); durationCell.dataset.label = "Duration"; row.appendChild(durationCell); @@ -309,6 +319,7 @@ function renderBuildCard(build) { const card = elem("section", "build-card"); card.dataset.artifactKey = build.artifactKey; card.dataset.startedAt = build.startedAt || ""; + card.dataset.runningSince = build.runningSince || ""; card.dataset.status = build.status || "running"; const header = elem("header", "build-card-header"); @@ -328,7 +339,9 @@ function renderBuildCard(build) { ); header.appendChild(commitCode); header.appendChild(elem("span", "muted", "started " + formatTimestamp(build.startedAt))); - header.appendChild(elem("span", "duration-cell running-duration", formatDuration(displayDurationSeconds(build)))); + header.appendChild( + elem("span", durationCellClass(build.status) + " running-duration", formatDuration(displayDurationSeconds(build))), + ); const cardActions = elem("span", "build-card-actions"); cardActions.appendChild( actionButton("× Cancel", "Cancel build", "cancel-button", { @@ -398,6 +411,7 @@ function initCurrentBuilds() { card = container.appendChild(renderBuildCard(build)); } else { card.dataset.status = build.status; + card.dataset.runningSince = build.runningSince || card.dataset.runningSince; const badge = card.querySelector(".status"); badge.className = statusCssClass(build.status); badge.textContent = build.status; @@ -453,16 +467,20 @@ function initSystemTable() { // ---- 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(); + // running: live build time since leaving the queue; pending: wait time (italic) + const basisIso = status === "running" + ? (element.dataset.runningSince || element.dataset.startedAt) + : element.dataset.startedAt; + const elapsed = elapsedSeconds(basisIso); const durationCell = element.querySelector(".duration-cell"); - if (durationCell && !Number.isNaN(startedAt)) { - durationCell.textContent = formatDuration((now - startedAt) / 1000); + if (durationCell && elapsed != null) { + durationCell.textContent = formatDuration(elapsed); + durationCell.classList.toggle("duration-wait", status === "pending"); } }); } diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index 9722981..449a9c3 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -24,7 +24,7 @@ No builds recorded yet. + th:attr="data-artifact-key=${row.artifactKey},data-branch=${row.branch},data-started-at=${row.startedAtIso},data-running-since=${row.runningSinceIso},data-status=${row.status}"> success diff --git a/src/main/resources/templates/current.html b/src/main/resources/templates/current.html index 3a4ce13..99de35a 100644 --- a/src/main/resources/templates/current.html +++ b/src/main/resources/templates/current.html @@ -10,7 +10,7 @@ No build is currently running.

+ th:attr="data-artifact-key=${build.artifactKey},data-started-at=${build.startedAtIso},data-running-since=${build.runningSinceIso},data-status=${build.status}">
running diff --git a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt index cecdc18..e0782fd 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt @@ -11,6 +11,8 @@ import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.longs.shouldBeLessThan import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -157,6 +159,51 @@ class BuildExecutorTest : FunSpec() { awaitStatus(h, "main", BuildStatus.CANCELLED) } + test("the duration measures build time only, not the queue wait") { + // the first build sleeps, the second (queued behind it) finishes instantly + val h = harness("test -f slow-done || { touch slow-done; sleep 2; }") + + h.executor.startBuild("main", "abc123", h.workingDir) + val second = h.executor.startBuild("main", "abc124", h.workingDir) + + eventually(30.seconds) { + h.repository + .history() + .first { it.artifactKey == second.artifactKey } + .status shouldBe BuildStatus.SUCCESS + } + val result = h.repository.history().first { it.artifactKey == second.artifactKey } + val runningSince = result.runningSince.shouldNotBeNull() + val waitMillis = + java.time.Duration + .between(result.startedAt, runningSince) + .toMillis() + waitMillis shouldBeGreaterThan 1000L + result.duration.shouldNotBeNull().toMillis() shouldBeLessThan waitMillis + } + + test("a build cancelled while still queued records neither runningSince nor a duration") { + val h = harness("sleep 30") + + val first = h.executor.startBuild("main", "abc123", h.workingDir) + val second = h.executor.startBuild("main", "abc124", h.workingDir) + eventually(30.seconds) { + h.executor.currentBuilds().map { it.artifactKey } shouldContain first.artifactKey + } + h.executor.cancel(second.artifactKey).shouldBeTrue() + h.executor.cancel(first.artifactKey).shouldBeTrue() + + eventually(30.seconds) { + h.repository + .history() + .first { it.artifactKey == second.artifactKey } + .status shouldBe BuildStatus.CANCELLED + } + val cancelled = h.repository.history().first { it.artifactKey == second.artifactKey } + cancelled.runningSince shouldBe null + cancelled.duration shouldBe null + } + test("a failing build command records FAILED with a duration") { val h = harness("exit 3")