Health: count only flows the engine acts on, format durations with their unit

The "running" tile counted paused and invalid flows as running, so it read
"5/5" beside "1 flow(s) cannot run". Its note is now additive rather than a
precedence chain, so a quarantine no longer hides the invalid count.

Adds dur() beside si(): a ms reading picks its own unit, so a slow run reads
"1.24 s" instead of "1.2k ms".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 08:24:57 +02:00
co-authored by Claude Opus 5
parent 9a44b89b9f
commit c005ea48e1
6 changed files with 93 additions and 20 deletions
+11 -1
View File
@@ -157,8 +157,18 @@ async def read_summary(
problems=problems,
flows={
"total": len(names),
# What the engine will actually act on. Enabled is not enough:
# validation stops a flow as surely as quarantine does, and a
# paused one is holding its messages rather than running them.
"running": len(
[n for n in names if controller.is_enabled(n) and n not in quarantined]
[
n
for n in names
if controller.is_enabled(n)
and n not in quarantined
and n not in paused
and n not in invalid
]
),
"paused": len(paused),
"quarantined": len(quarantined),
@@ -91,6 +91,10 @@ def test_a_flow_that_cannot_run_makes_the_summary_degraded(
controller = client.app.state.flow_controller
before = controller.issues
before_flows = controller.store.list_flows
# The looping flow has to be one the store lists, otherwise "running"
# counts nothing either way and the assertion below proves nothing.
controller.store.list_flows = lambda: ["looping"]
controller.issues = [
ValidationIssue(
code="cycle",
@@ -110,9 +114,11 @@ def test_a_flow_that_cannot_run_makes_the_summary_degraded(
body = client.get(f"{PREFIX}/summary", headers=superuser_token_headers).json()
finally:
controller.issues = before
controller.store.list_flows = before_flows
assert body["status"] == "degraded"
assert body["flows"]["invalid"] == 1
assert body["flows"]["running"] == 0
assert any("looping" in problem for problem in body["problems"])
assert not any("broken" in problem for problem in body["problems"])
assert not any("hooky" in problem for problem in body["problems"])
@@ -8,7 +8,7 @@ import { UplotChart } from "@/components/Common/UplotChart"
import { useEngineEvents } from "@/components/Flow/liveStore"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { cn, si } from "@/lib/utils"
import { cn, dur } from "@/lib/utils"
import {
ago,
auditQueryOptions,
@@ -319,8 +319,8 @@ export function HealthActivity({ range }: { range: Range }) {
>
{run.status}
</span>
<span className="w-16 shrink-0 text-right text-muted-foreground">
{si(run.duration_ms)} ms
<span className="w-20 shrink-0 whitespace-nowrap text-right text-muted-foreground">
{dur(run.duration_ms)}
</span>
<span className="w-16 shrink-0 text-right text-xs text-muted-foreground">
{ago(run.started_at)}
@@ -6,7 +6,7 @@ import { type Range, RangePicker } from "@/components/Common/RangePicker"
import { Sparkline } from "@/components/Common/Sparkline"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Badge } from "@/components/ui/badge"
import { si } from "@/lib/utils"
import { dur, si } from "@/lib/utils"
import {
ago,
CARD,
@@ -117,16 +117,18 @@ export function HealthOverview({
<Tile
label="Flows running"
value={`${summary?.flows.running ?? 0}/${summary?.flows.total ?? 0}`}
// Worst first: the engine gave up on a quarantined flow, a flow
// validation blocks never started, and a paused one is deliberate.
// Worst first, but all of them: a quarantine used to hide the
// flows validation blocks, so the tile disagreed with the count
// beside it. Zero and undefined both drop out of the filter.
note={
summary?.flows.quarantined
? `${summary.flows.quarantined} quarantined`
: summary?.flows.invalid
? `${summary.flows.invalid} cannot run`
: summary?.flows.paused
? `${summary.flows.paused} paused`
: "none paused"
[
summary?.flows.quarantined &&
`${summary.flows.quarantined} quarantined`,
summary?.flows.invalid && `${summary.flows.invalid} cannot run`,
summary?.flows.paused && `${summary.flows.paused} paused`,
]
.filter(Boolean)
.join(" · ") || "none paused"
}
/>
<Tile
@@ -149,8 +151,8 @@ export function HealthOverview({
/>
<Tile
label="Loop lag"
value={`${si(summary?.loop_lag.ewma ?? 0)} ms`}
note={`peak ${si(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
value={dur(summary?.loop_lag.ewma ?? 0)}
note={`peak ${dur(summary?.loop_lag.max_60s ?? 0)} in the last minute`}
/>
</div>
</section>
@@ -208,10 +210,10 @@ export function HealthOverview({
)}
</td>
<td className="whitespace-nowrap px-3 py-2 text-center">
{si(row.avg_ms)} ms
{dur(row.avg_ms)}
</td>
<td className="whitespace-nowrap px-3 py-2 text-center">
{si(row.avg_lag_ms)} ms
{dur(row.avg_lag_ms)}
</td>
{/* The dot straddles the curve's right edge, so the cell
keeps a little room for the half that hangs out. */}
+22 -2
View File
@@ -1,12 +1,13 @@
/**
* The cases `si` gets wrong when its rounding is written the obvious way.
* The cases `si` and `dur` get wrong when their rounding is written the
* obvious way.
*
* Run: `bun src/lib/utils.check.ts` (there is no unit runner; the suite in
* `tests/` drives a running stack).
*/
import assert from "node:assert/strict"
import { si } from "./utils"
import { dur, si } from "./utils"
// Nothing is prefixed in the plain window: these are written with a unit after
// them, and "400m ms" is not an improvement on "0.4 ms".
@@ -30,3 +31,22 @@ assert.notEqual(si(1234, 4), si(1235, 4))
assert.equal(si(Number.NaN), "--")
console.log("si: ok")
// The unit follows the size, so no reading is left as a shortened number in
// front of a unit the call site guessed.
assert.equal(dur(0), "0 ms")
assert.equal(dur(203.63), "204 ms")
assert.equal(dur(1240), "1.24 s")
assert.equal(dur(90_000), "1.5 min")
// The plain window down to 0.01 ms, so a fast run keeps one unit across polls.
assert.equal(dur(0.4), "0.4 ms")
assert.equal(dur(0.0031, 4), "3.1 µs")
// Rounding must not leave a value in the step it just rounded out of.
assert.equal(dur(999.7), "1 s")
assert.equal(dur(59_999), "1 min")
assert.equal(dur(Number.NaN), "--")
console.log("dur: ok")
+35
View File
@@ -41,3 +41,38 @@ export function si(value: number, digits = 3): string {
const [factor, suffix] = SI_STEPS.find(([step]) => size >= step) ?? [1, ""]
return `${Number((rounded / factor).toPrecision(digits))}${suffix}`
}
/** Floor, divisor and unit per step, largest first. */
const DUR_STEPS: [number, number, string][] = [
[60_000, 60_000, "min"],
[1_000, 1_000, "s"],
[0.01, 1, "ms"],
[0, 1e-3, "µs"],
]
/**
* A duration in milliseconds at `digits` significant digits, carrying the unit
* it picked: 1240 → "1.24 s", 90000 → "1.5 min", 203.63 → "204 ms".
*
* Unlike `si` the unit is not the caller's to write. Shortening the number in
* front of a fixed " ms" gives "1.2k ms", which is a duration nobody reads —
* so the step moves the unit instead, and the value is rounded before that
* step is picked, so 999.7 reads "1 s" rather than "1000 ms".
*
* Below a millisecond it holds down to 0.01, which is `si`'s own plain-number
* window transposed onto units. That floor is deliberate: without it a tile
* polled every ten seconds flips between "400 µs" and "1.2 ms", and a reading
* that changes unit on every poll reads as broken rather than as fast.
*/
export function dur(ms: number, digits = 3): string {
if (!Number.isFinite(ms)) return "--"
const rounded = Number(ms.toPrecision(digits))
const size = Math.abs(rounded)
if (size === 0) return "0 ms"
const [, factor, unit] = DUR_STEPS.find(([floor]) => size >= floor) ?? [
0,
1,
"ms",
]
return `${Number((rounded / factor).toPrecision(digits))} ${unit}`
}