diff --git a/docs/prs/2026-08-10-PR#000-highlight-critical-utilization.md b/docs/prs/2026-08-10-PR#000-highlight-critical-utilization.md new file mode 100644 index 0000000..7f5abf4 --- /dev/null +++ b/docs/prs/2026-08-10-PR#000-highlight-critical-utilization.md @@ -0,0 +1,61 @@ +> **WARNING:** This document describes only the change applied in this PR. +> It may already be outdated once the next PR is merged. +> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation. + +## The Problem + +The system page shows CPU, RAM, and disk usage as plain numbers. +Whether a value is harmless or critical only becomes clear after mentally comparing it against the totals in the info line. +Critical utilization should be visible at a glance. + +## Non-Goals + +- No configurable thresholds; 80%/90% are hardcoded like the rest of the UI styling. +- No highlighting of min/max/avg — they are historical, only the current value is actionable. +- No alerting or notifications; this is display only. + +## The Scenarios + +### Feature: critical utilization is highlighted on the system page + +#### Background + +- The utilization metrics with a total are: CPU used vs. CPU count, RAM used vs. RAM total, disk used vs. disk total. +- Free/idle rows and the repo size have no meaningful utilization ratio and are never highlighted. + +#### Scenario#000.01: The current value is highlighted from 80% (warn) and 90% (critical) of its total + +So that critical load is visible at a glance. + +- **Given** a utilization metric with an available total +- **When** the current value reaches 80% of the total +- **Then** the Current cell is highlighted orange (`metric-warn`) + - **and** from 90% it is highlighted red (`metric-crit`) + - **and** below 80% it stays unstyled + +##### Verified by + +- [UiViewsTest — "utilization highlights warn from 80% and crit from 90% of the total"](../../src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt) +- [UiViewsTest — "only the used rows with a total get the critical highlighting"](../../src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt) + +#### Scenario#000.02: Unavailable metrics are never highlighted + +So that hosts without `/proc` (no metrics, `n/a` cells) render unchanged. + +- **Given** a metric or its total is unavailable (null, NaN, or zero) +- **When** the system page renders or polls +- **Then** no highlighting class is applied + +##### Verified by + +- [UiViewsTest — "utilization highlighting is off when a value or the total is unavailable"](../../src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt) + +## The Solution + +`UiFormats.utilizationClass(used, total)` returns `""`/`metric-warn`/`metric-crit`; `gittally.js` mirrors it in `utilizationClass` — per the UI invariant, server-rendered HTML and the polling script produce identical output. +`MetricRowView` carries a `currentClass` applied via `th:classappend`; the poller toggles the same classes on the Current cell using a metric-to-total map (`UTILIZATION_TOTALS`). +The CSS reuses the existing badge color variables (`--interrupted-*` for warn, `--failed-*` for critical), so light and dark mode work without new colors. + +## Follow-up PRs + +- None planned. diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 12161f8..9a744ca 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -65,6 +65,29 @@ object UiFormats { } fun timeOfDay(instant: Instant): String = timeOfDayFormat.format(instant) + + /** + * CSS class highlighting a critical utilization: `metric-warn` from 80% of [total], + * `metric-crit` from 90%, empty below or when either value is unavailable. + * `gittally.js` (`utilizationClass`) must apply the same thresholds. + */ + fun utilizationClass( + used: Double?, + total: Double?, + ): String { + if (used == null || total == null || !used.isFinite() || !total.isFinite() || total <= 0) { + return "" + } + val ratio = used / total + return when { + ratio >= UTILIZATION_CRIT -> "metric-crit" + ratio >= UTILIZATION_WARN -> "metric-warn" + else -> "" + } + } + + private const val UTILIZATION_WARN = 0.80 + private const val UTILIZATION_CRIT = 0.90 } /** One row of the latest/history build tables; [latestGreenUrl] only on the branches view. */ @@ -161,12 +184,15 @@ data class MetricRowView( val min: String, val max: String, val avg: String, + /** `metric-warn`/`metric-crit` when the current value is a critical share of its total. */ + val currentClass: String = "", ) { companion object { fun from( key: String, label: String, aggregate: MetricAggregate?, + total: Double? = null, ) = MetricRowView( key = key, label = label, @@ -174,6 +200,7 @@ data class MetricRowView( min = UiFormats.metric(aggregate?.min), max = UiFormats.metric(aggregate?.max), avg = UiFormats.metric(aggregate?.avg), + currentClass = UiFormats.utilizationClass(aggregate?.current, total), ) } } @@ -191,11 +218,11 @@ data class SystemMetricsView( SystemMetricsView( rows = listOf( - MetricRowView.from("cpuUsed", "CPU used (cores)", metrics.cpuUsed), + MetricRowView.from("cpuUsed", "CPU used (cores)", metrics.cpuUsed, metrics.cpuCount.toDouble()), MetricRowView.from("cpuIdle", "CPU idle (cores)", metrics.cpuIdle), - MetricRowView.from("ramUsedGib", "RAM used (GiB)", metrics.ramUsedGib), + MetricRowView.from("ramUsedGib", "RAM used (GiB)", metrics.ramUsedGib, metrics.ramTotalGib), MetricRowView.from("ramFreeGib", "RAM free (GiB)", metrics.ramFreeGib), - MetricRowView.from("diskUsedGib", "Disk used (GiB)", metrics.diskUsedGib), + MetricRowView.from("diskUsedGib", "Disk used (GiB)", metrics.diskUsedGib, metrics.diskTotalGib), MetricRowView.from("diskFreeGib", "Disk free (GiB)", metrics.diskFreeGib), MetricRowView.from("repoSizeGib", "Repo size (GiB)", metrics.repoSizeGib), ), diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css index 495e022..89e777a 100644 --- a/src/main/resources/static/gittally.css +++ b/src/main/resources/static/gittally.css @@ -95,6 +95,9 @@ tbody.is-stale { opacity: 0.55; } /* system metrics */ #system-table { min-width: 640px; } .num { text-align: right; font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } +/* critical utilization of the current value: warn from 80% of the total, critical from 90% */ +.metric-warn { background: var(--interrupted-bg); color: var(--interrupted-text); font-weight: 700; } +.metric-crit { background: var(--failed-bg); color: var(--failed-text); font-weight: 700; } th.num { font-family: inherit; font-size: 12px; } .meta { margin: 14px 0 0; color: var(--muted); font-size: 13px; display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 6px; } diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index 05c988f..dc4366e 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -433,6 +433,24 @@ function initCurrentBuilds() { // ---- system metrics ---------------------------------------------------------- +/** The total each utilization metric is compared against for the critical highlighting. */ +const UTILIZATION_TOTALS = { cpuUsed: "cpuCount", ramUsedGib: "ramTotalGib", diskUsedGib: "diskTotalGib" }; + +/** Same thresholds as UiFormats.utilizationClass: warn from 80% of the total, critical from 90%. */ +function utilizationClass(used, total) { + if (used == null || total == null || !(total > 0)) { + return ""; + } + const ratio = used / total; + if (ratio >= 0.90) { + return "metric-crit"; + } + if (ratio >= 0.80) { + return "metric-warn"; + } + return ""; +} + /** The metric rows are fixed, so only the cell texts are updated — never rebuilt. */ function initSystemTable() { const table = document.getElementById("system-table"); @@ -454,6 +472,15 @@ function initSystemTable() { row.querySelectorAll("[data-field]").forEach((cell) => { cell.textContent = formatMetric(aggregate ? aggregate[cell.dataset.field] : null); }); + const currentCell = row.querySelector('[data-field="current"]'); + if (currentCell) { + const totalField = UTILIZATION_TOTALS[row.dataset.metric]; + const cssClass = totalField + ? utilizationClass(aggregate ? aggregate.current : null, metrics[totalField]) + : ""; + currentCell.classList.toggle("metric-warn", cssClass === "metric-warn"); + currentCell.classList.toggle("metric-crit", cssClass === "metric-crit"); + } }); setText("info-cpu-count", metrics.cpuCount != null ? metrics.cpuCount + " cores" : "n/a"); setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a"); diff --git a/src/main/resources/templates/system.html b/src/main/resources/templates/system.html index 3fde41e..da82a23 100644 --- a/src/main/resources/templates/system.html +++ b/src/main/resources/templates/system.html @@ -19,7 +19,7 @@ CPU used (cores) - 0.42 + 0.42 0.10 3.20 0.80 diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt index 3e932cc..f7aa175 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiViewsTest.kt @@ -1,9 +1,12 @@ package de.hoennig.gittally.server import de.hoennig.gittally.config.GiteaConfig +import de.hoennig.gittally.metrics.MetricAggregate +import de.hoennig.gittally.metrics.SystemMetrics import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.time.Duration +import java.time.Instant class UiViewsTest : FunSpec() { init { @@ -22,6 +25,52 @@ class UiViewsTest : FunSpec() { UiFormats.metric(31.288) shouldBe "31.29" } + test("utilization highlights warn from 80% and crit from 90% of the total") { + UiFormats.utilizationClass(7.9, 10.0) shouldBe "" + UiFormats.utilizationClass(8.0, 10.0) shouldBe "metric-warn" + UiFormats.utilizationClass(8.9, 10.0) shouldBe "metric-warn" + UiFormats.utilizationClass(9.0, 10.0) shouldBe "metric-crit" + UiFormats.utilizationClass(11.0, 10.0) shouldBe "metric-crit" + } + + test("utilization highlighting is off when a value or the total is unavailable") { + UiFormats.utilizationClass(null, 10.0) shouldBe "" + UiFormats.utilizationClass(9.5, null) shouldBe "" + UiFormats.utilizationClass(Double.NaN, 10.0) shouldBe "" + UiFormats.utilizationClass(9.5, 0.0) shouldBe "" + } + + test("only the used rows with a total get the critical highlighting") { + val aggregate = { value: Double -> MetricAggregate(current = value, min = 0.0, max = value, avg = value) } + val view = + SystemMetricsView.from( + SystemMetrics( + timestamp = Instant.parse("2026-08-10T12:00:00Z"), + sampleCount = 1, + cpuCount = 4, + ramTotalGib = 8.0, + diskTotalGib = 100.0, + cpuUsed = aggregate(3.7), + cpuIdle = aggregate(0.3), + ramUsedGib = aggregate(6.5), + ramFreeGib = aggregate(1.5), + diskUsedGib = aggregate(70.0), + diskFreeGib = aggregate(30.0), + repoSizeGib = aggregate(95.0), + ), + ) + + val classesByKey = view.rows.associate { it.key to it.currentClass } + classesByKey["cpuUsed"] shouldBe "metric-crit" + classesByKey["ramUsedGib"] shouldBe "metric-warn" + classesByKey["diskUsedGib"] shouldBe "" + // free/idle and the repo size have no meaningful utilization ratio + classesByKey["cpuIdle"] shouldBe "" + classesByKey["ramFreeGib"] shouldBe "" + classesByKey["diskFreeGib"] shouldBe "" + classesByKey["repoSizeGib"] shouldBe "" + } + test("Gitea web links escape branch segments but keep slashes") { val links = GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))