diff --git a/backend/app/api/routes/observability.py b/backend/app/api/routes/observability.py index a45c974..2134035 100644 --- a/backend/app/api/routes/observability.py +++ b/backend/app/api/routes/observability.py @@ -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), diff --git a/backend/tests/api/routes/test_observability.py b/backend/tests/api/routes/test_observability.py index 494541a..24ba915 100644 --- a/backend/tests/api/routes/test_observability.py +++ b/backend/tests/api/routes/test_observability.py @@ -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"]) diff --git a/frontend/src/components/Health/HealthActivity.tsx b/frontend/src/components/Health/HealthActivity.tsx index a409485..2695192 100644 --- a/frontend/src/components/Health/HealthActivity.tsx +++ b/frontend/src/components/Health/HealthActivity.tsx @@ -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} - - {si(run.duration_ms)} ms + + {dur(run.duration_ms)} {ago(run.started_at)} diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index 6870898..3dd8705 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -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({ @@ -208,10 +210,10 @@ export function HealthOverview({ )} - {si(row.avg_ms)} ms + {dur(row.avg_ms)} - {si(row.avg_lag_ms)} ms + {dur(row.avg_lag_ms)} {/* The dot straddles the curve's right edge, so the cell keeps a little room for the half that hangs out. */} diff --git a/frontend/src/lib/utils.check.ts b/frontend/src/lib/utils.check.ts index 08e27c9..a0c7d84 100644 --- a/frontend/src/lib/utils.check.ts +++ b/frontend/src/lib/utils.check.ts @@ -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") diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a9d9260..acaca2a 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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}` +}