implemented 09-system-metrics.md: added system metrics: introduced system monitoring with a metrics collector, REST API endpoint, Thymeleaf UI rendering, and lifecycle management

This commit is contained in:
Michael Hoennig
2026-07-07 12:55:11 +02:00
parent 67c9f0ade9
commit a6a2c9de0b
21 changed files with 1069 additions and 6 deletions
@@ -0,0 +1,28 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.metrics.SystemMetricsCollector
import jakarta.annotation.PreDestroy
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
/**
* Starts the system-metrics sampling loop once the server context is ready and
* stops it on shutdown. Only in the `server` profile, like [ServerWatcherLifecycle].
*/
@Component
@Profile("server")
class ServerMetricsLifecycle(
private val collector: SystemMetricsCollector,
) {
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
collector.start()
}
@PreDestroy
fun onShutdown() {
collector.stop()
}
}
@@ -0,0 +1,18 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.metrics.SystemMetrics
import de.hoennig.gittally.metrics.SystemMetricsCollector
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class SystemApiController(
private val collector: SystemMetricsCollector,
) {
/**
* The current system snapshot plus min/max/avg aggregates (the legacy
* `system.json`). Unavailable metrics are null, never an error.
*/
@GetMapping("/api/system")
fun system(): SystemMetrics = collector.snapshot()
}
@@ -5,6 +5,7 @@ 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 de.hoennig.gittally.metrics.SystemMetricsCollector
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.http.HttpStatus
@@ -32,6 +33,7 @@ class UiController(
private val artifactStore: ArtifactStore,
private val controlTokens: ControlTokenService,
private val configLoader: ConfigLoader,
private val metricsCollector: SystemMetricsCollector,
private val buildProperties: ObjectProvider<BuildProperties>,
) {
var workingDir: Path = Paths.get(".")
@@ -80,6 +82,13 @@ class UiController(
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(
@@ -2,12 +2,15 @@ package de.hoennig.gittally.server
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.config.GiteaConfig
import de.hoennig.gittally.metrics.MetricAggregate
import de.hoennig.gittally.metrics.SystemMetrics
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
import java.util.Locale
/** Links into the Gitea web UI, like legacy `gitea_branch_web_url`; null when Gitea is not configured. */
class GiteaWebLinks(
@@ -33,6 +36,8 @@ class GiteaWebLinks(
object UiFormats {
private val timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
private val timeOfDayFormat = DateTimeFormatter.ofPattern("HH:mm:ss").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. */
@@ -50,6 +55,16 @@ object UiFormats {
"%d:%02d".format(minutes, rest)
}
}
/** Two-decimal metric value with a dot like JS `toFixed(2)`; `n/a` when the source is unavailable. */
fun metric(value: Double?): String =
if (value == null || !value.isFinite()) {
"n/a"
} else {
String.format(Locale.ROOT, "%.2f", value)
}
fun timeOfDay(instant: Instant): String = timeOfDayFormat.format(instant)
}
/** One row of the latest/history build tables. */
@@ -96,3 +111,60 @@ data class CurrentBuildView(
val branchUrl: String?,
val commitUrl: String?,
)
/**
* One row of the system-metrics table. The [key] matches the JSON field of
* `GET /api/system`, so `gittally.js` can update the cells in place.
*/
data class MetricRowView(
val key: String,
val label: String,
val current: String,
val min: String,
val max: String,
val avg: String,
) {
companion object {
fun from(
key: String,
label: String,
aggregate: MetricAggregate?,
) = MetricRowView(
key = key,
label = label,
current = UiFormats.metric(aggregate?.current),
min = UiFormats.metric(aggregate?.min),
max = UiFormats.metric(aggregate?.max),
avg = UiFormats.metric(aggregate?.avg),
)
}
}
/** The system view: the metric rows plus the totals/updated info line, like the legacy system page. */
data class SystemMetricsView(
val rows: List<MetricRowView>,
val cpuCount: String,
val ramTotal: String,
val diskTotal: String,
val updated: String,
) {
companion object {
fun from(metrics: SystemMetrics) =
SystemMetricsView(
rows =
listOf(
MetricRowView.from("cpuUsed", "CPU used (cores)", metrics.cpuUsed),
MetricRowView.from("cpuIdle", "CPU idle (cores)", metrics.cpuIdle),
MetricRowView.from("ramUsedGib", "RAM used (GiB)", metrics.ramUsedGib),
MetricRowView.from("ramFreeGib", "RAM free (GiB)", metrics.ramFreeGib),
MetricRowView.from("diskUsedGib", "Disk used (GiB)", metrics.diskUsedGib),
MetricRowView.from("diskFreeGib", "Disk free (GiB)", metrics.diskFreeGib),
MetricRowView.from("repoSizeGib", "Repo size (GiB)", metrics.repoSizeGib),
),
cpuCount = "${metrics.cpuCount} cores",
ramTotal = metrics.ramTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
diskTotal = metrics.diskTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
updated = metrics.timestamp?.let { UiFormats.timeOfDay(it) } ?: "n/a",
)
}
}