Highlight critical utilization on the system page (v0.9.6)

The Current cell of CPU/RAM/disk used turns orange from 80% of the
total and red from 90%. UiFormats.utilizationClass and the mirrored
utilizationClass in gittally.js apply the same thresholds; unavailable
metrics (n/a) are never highlighted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-10 19:08:23 +02:00
co-authored by Claude Fable 5
parent 428d1a06cb
commit f2d4dbfda0
6 changed files with 171 additions and 4 deletions
@@ -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.
@@ -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),
),
+3
View File
@@ -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; }
+27
View File
@@ -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");
+1 -1
View File
@@ -19,7 +19,7 @@
<tbody id="system-rows">
<tr th:each="row : ${metrics.rows}" th:attr="data-metric=${row.key}">
<td data-label="Metric" th:text="${row.label}">CPU used (cores)</td>
<td class="num" data-label="Current" data-field="current" th:text="${row.current}">0.42</td>
<td class="num" data-label="Current" data-field="current" th:classappend="${row.currentClass}" th:text="${row.current}">0.42</td>
<td class="num" data-label="Min" data-field="min" th:text="${row.min}">0.10</td>
<td class="num" data-label="Max" data-field="max" th:text="${row.max}">3.20</td>
<td class="num" data-label="Avg" data-field="avg" th:text="${row.avg}">0.80</td>
@@ -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"))