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:
co-authored by
Claude Fable 5
parent
428d1a06cb
commit
f2d4dbfda0
@@ -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)
|
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. */
|
/** 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 min: String,
|
||||||
val max: String,
|
val max: String,
|
||||||
val avg: String,
|
val avg: String,
|
||||||
|
/** `metric-warn`/`metric-crit` when the current value is a critical share of its total. */
|
||||||
|
val currentClass: String = "",
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(
|
fun from(
|
||||||
key: String,
|
key: String,
|
||||||
label: String,
|
label: String,
|
||||||
aggregate: MetricAggregate?,
|
aggregate: MetricAggregate?,
|
||||||
|
total: Double? = null,
|
||||||
) = MetricRowView(
|
) = MetricRowView(
|
||||||
key = key,
|
key = key,
|
||||||
label = label,
|
label = label,
|
||||||
@@ -174,6 +200,7 @@ data class MetricRowView(
|
|||||||
min = UiFormats.metric(aggregate?.min),
|
min = UiFormats.metric(aggregate?.min),
|
||||||
max = UiFormats.metric(aggregate?.max),
|
max = UiFormats.metric(aggregate?.max),
|
||||||
avg = UiFormats.metric(aggregate?.avg),
|
avg = UiFormats.metric(aggregate?.avg),
|
||||||
|
currentClass = UiFormats.utilizationClass(aggregate?.current, total),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,11 +218,11 @@ data class SystemMetricsView(
|
|||||||
SystemMetricsView(
|
SystemMetricsView(
|
||||||
rows =
|
rows =
|
||||||
listOf(
|
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("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("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("diskFreeGib", "Disk free (GiB)", metrics.diskFreeGib),
|
||||||
MetricRowView.from("repoSizeGib", "Repo size (GiB)", metrics.repoSizeGib),
|
MetricRowView.from("repoSizeGib", "Repo size (GiB)", metrics.repoSizeGib),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ tbody.is-stale { opacity: 0.55; }
|
|||||||
/* system metrics */
|
/* system metrics */
|
||||||
#system-table { min-width: 640px; }
|
#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; }
|
.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; }
|
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; }
|
.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; }
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,24 @@ function initCurrentBuilds() {
|
|||||||
|
|
||||||
// ---- system metrics ----------------------------------------------------------
|
// ---- 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. */
|
/** The metric rows are fixed, so only the cell texts are updated — never rebuilt. */
|
||||||
function initSystemTable() {
|
function initSystemTable() {
|
||||||
const table = document.getElementById("system-table");
|
const table = document.getElementById("system-table");
|
||||||
@@ -454,6 +472,15 @@ function initSystemTable() {
|
|||||||
row.querySelectorAll("[data-field]").forEach((cell) => {
|
row.querySelectorAll("[data-field]").forEach((cell) => {
|
||||||
cell.textContent = formatMetric(aggregate ? aggregate[cell.dataset.field] : null);
|
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-cpu-count", metrics.cpuCount != null ? metrics.cpuCount + " cores" : "n/a");
|
||||||
setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a");
|
setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a");
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<tbody id="system-rows">
|
<tbody id="system-rows">
|
||||||
<tr th:each="row : ${metrics.rows}" th:attr="data-metric=${row.key}">
|
<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 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="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="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>
|
<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
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
import de.hoennig.gittally.config.GiteaConfig
|
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.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
class UiViewsTest : FunSpec() {
|
class UiViewsTest : FunSpec() {
|
||||||
init {
|
init {
|
||||||
@@ -22,6 +25,52 @@ class UiViewsTest : FunSpec() {
|
|||||||
UiFormats.metric(31.288) shouldBe "31.29"
|
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") {
|
test("Gitea web links escape branch segments but keep slashes") {
|
||||||
val links =
|
val links =
|
||||||
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
||||||
|
|||||||
Reference in New Issue
Block a user