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
@@ -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"))