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:
@@ -0,0 +1,81 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.metrics.MetricAggregate
|
||||
import de.hoennig.gittally.metrics.SystemMetrics
|
||||
import de.hoennig.gittally.metrics.SystemMetricsCollector
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
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.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(SystemApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class SystemApiControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var collector: SystemMetricsCollector
|
||||
|
||||
private val emptySnapshot =
|
||||
SystemMetrics(
|
||||
timestamp = null,
|
||||
sampleCount = 0,
|
||||
cpuCount = 8,
|
||||
ramTotalGib = null,
|
||||
diskTotalGib = null,
|
||||
cpuUsed = null,
|
||||
cpuIdle = null,
|
||||
ramUsedGib = null,
|
||||
ramFreeGib = null,
|
||||
diskUsedGib = null,
|
||||
diskFreeGib = null,
|
||||
repoSizeGib = null,
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach { clearMocks(collector) }
|
||||
|
||||
test("the system endpoint answers snapshot and aggregates") {
|
||||
every { collector.snapshot() } returns
|
||||
emptySnapshot.copy(
|
||||
timestamp = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
sampleCount = 5,
|
||||
ramTotalGib = 32.0,
|
||||
cpuUsed = MetricAggregate(current = 1.0, min = 0.5, max = 2.0, avg = 1.25),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/system"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.timestamp").value("2026-07-07T10:00:00Z"))
|
||||
.andExpect(jsonPath("$.sampleCount").value(5))
|
||||
.andExpect(jsonPath("$.cpuCount").value(8))
|
||||
.andExpect(jsonPath("$.ramTotalGib").value(32.0))
|
||||
.andExpect(jsonPath("$.cpuUsed.current").value(1.0))
|
||||
.andExpect(jsonPath("$.cpuUsed.min").value(0.5))
|
||||
.andExpect(jsonPath("$.cpuUsed.max").value(2.0))
|
||||
.andExpect(jsonPath("$.cpuUsed.avg").value(1.25))
|
||||
}
|
||||
|
||||
test("unavailable metrics are explicit nulls, and the endpoint still answers 200") {
|
||||
every { collector.snapshot() } returns emptySnapshot
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/system"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.sampleCount").value(0))
|
||||
.andExpect(jsonPath("$.timestamp").value(nullValue()))
|
||||
.andExpect(jsonPath("$.cpuUsed").value(nullValue()))
|
||||
.andExpect(jsonPath("$.ramUsedGib").value(nullValue()))
|
||||
.andExpect(jsonPath("$.repoSizeGib").value(nullValue()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ 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 de.hoennig.gittally.metrics.MetricAggregate
|
||||
import de.hoennig.gittally.metrics.SystemMetrics
|
||||
import de.hoennig.gittally.metrics.SystemMetricsCollector
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -49,8 +52,27 @@ class UiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var configLoader: ConfigLoader
|
||||
|
||||
@MockkBean
|
||||
lateinit var metricsCollector: SystemMetricsCollector
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val emptySystemMetrics =
|
||||
SystemMetrics(
|
||||
timestamp = null,
|
||||
sampleCount = 0,
|
||||
cpuCount = 8,
|
||||
ramTotalGib = null,
|
||||
diskTotalGib = null,
|
||||
cpuUsed = null,
|
||||
cpuIdle = null,
|
||||
ramUsedGib = null,
|
||||
ramFreeGib = null,
|
||||
diskUsedGib = null,
|
||||
diskFreeGib = null,
|
||||
repoSizeGib = null,
|
||||
)
|
||||
|
||||
private val successResult =
|
||||
BuildResult(
|
||||
branch = "main",
|
||||
@@ -63,7 +85,7 @@ class UiControllerTest : FunSpec() {
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader)
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector)
|
||||
every { configLoader.load(any()) } returns
|
||||
GitTallyConfig(
|
||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||
@@ -191,6 +213,36 @@ class UiControllerTest : FunSpec() {
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("system view renders metric rows, totals, and the polling hook") {
|
||||
every { metricsCollector.snapshot() } returns
|
||||
emptySystemMetrics.copy(
|
||||
timestamp = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
sampleCount = 5,
|
||||
ramTotalGib = 32.0,
|
||||
cpuUsed = MetricAggregate(current = 1.0, min = 0.5, max = 2.0, avg = 1.25),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/system"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("""data-api="/api/system"""")))
|
||||
.andExpect(content().string(containsString("""data-metric="cpuUsed"""")))
|
||||
.andExpect(content().string(containsString("CPU used (cores)")))
|
||||
.andExpect(content().string(containsString("1.25")))
|
||||
.andExpect(content().string(containsString("8 cores")))
|
||||
.andExpect(content().string(containsString("32.00 GiB")))
|
||||
}
|
||||
|
||||
test("system view renders n/a for unavailable metrics") {
|
||||
every { metricsCollector.snapshot() } returns emptySystemMetrics
|
||||
|
||||
mockMvc
|
||||
.perform(get("/system"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("Repo size (GiB)")))
|
||||
.andExpect(content().string(containsString("n/a")))
|
||||
}
|
||||
|
||||
test("branch names with HTML metacharacters render escaped") {
|
||||
val nasty = "feat/<script>alert('x')</script>"
|
||||
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
|
||||
|
||||
@@ -14,6 +14,14 @@ class UiViewsTest : FunSpec() {
|
||||
UiFormats.duration(Duration.ofSeconds(3600 + 62)) shouldBe "1:01:02"
|
||||
}
|
||||
|
||||
test("metric values format with two decimals and a dot, n/a when unavailable") {
|
||||
UiFormats.metric(null) shouldBe "n/a"
|
||||
UiFormats.metric(Double.NaN) shouldBe "n/a"
|
||||
UiFormats.metric(0.0) shouldBe "0.00"
|
||||
UiFormats.metric(1.234) shouldBe "1.23"
|
||||
UiFormats.metric(31.288) shouldBe "31.29"
|
||||
}
|
||||
|
||||
test("Gitea web links escape branch segments but keep slashes") {
|
||||
val links =
|
||||
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
||||
|
||||
Reference in New Issue
Block a user