From 6bc71ddaf3155ad3eadd8f9fe74130650c3f838f Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 27 Aug 2026 15:54:06 +0200 Subject: [PATCH] Record flags in history and draw them as steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bool was excluded from the history as "not a measurement", so a true/false port had no curve in the node panel and none on an edge — only the word. It is recorded as 0/1 now and drawn as steps, since a bezier through two states slopes through readings that never happened. The axis is pinned to 0..1, so a flag that was never on sits at the floor rather than mid-box. Co-Authored-By: Claude Opus 5 (1M context) --- backend/fluksio/api/routes/flows.py | 2 +- backend/fluksio/api/routes/messages.py | 2 +- backend/fluksio/flow/state.py | 16 +++++--- backend/tests/flow/test_history.py | 10 +++-- frontend/src/components/Common/Sparkline.tsx | 39 +++++++++++++------ .../src/components/Flow/EdgeInspector.tsx | 10 ++--- .../src/components/Flow/MessageSparkline.tsx | 11 ++++-- 7 files changed, 59 insertions(+), 31 deletions(-) diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 5e440e5..a9d5d30 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -837,7 +837,7 @@ def read_message_history( """The recent values of one message, for plotting. ``message`` may be given bare or qualified; a message that never carried a - number comes back with an empty series. + number or a flag comes back with an empty series. """ _read_flow(controller, name) key = qualify(name, message) diff --git a/backend/fluksio/api/routes/messages.py b/backend/fluksio/api/routes/messages.py index ee3d068..9c2f776 100644 --- a/backend/fluksio/api/routes/messages.py +++ b/backend/fluksio/api/routes/messages.py @@ -102,7 +102,7 @@ async def publish_message( @router.get("/{name}/history", response_model=MessagePoints) def read_message_history(name: str, controller: FlowControllerDep) -> Any: - """The series behind a chart. Numbers only — nothing else plots.""" + """The series behind a chart. Numbers and flags — nothing else plots.""" if controller.pipeline is None: return MessagePoints(message=name, numeric=False, points=[]) series = controller.state.history(name) diff --git a/backend/fluksio/flow/state.py b/backend/fluksio/flow/state.py index 9e10b00..a3b646d 100644 --- a/backend/fluksio/flow/state.py +++ b/backend/fluksio/flow/state.py @@ -24,16 +24,20 @@ import redis #: not JSON at all, and a datetime serialises rather than raising. _JSON_OPTS = orjson.OPT_NON_STR_KEYS -# A sparkline only means something for numbers, so the history keeps the values -# it can plot and nothing else. 120 points fill a panel-wide chart while leaving -# Redis a cache rather than a time-series database. +# A sparkline only means something for a value that can be placed on an axis, +# so the history keeps those and nothing else. 120 points fill a panel-wide +# chart while leaving Redis a cache rather than a time-series database. HISTORY_LIMIT = 120 def as_number(value: Any) -> float | None: - """The plottable form of a value, or None if it is not a number.""" - # bool is an int subclass; a flag is not a measurement. - if isinstance(value, bool) or not isinstance(value, (int, float)): + """The plottable form of a value, or None if it cannot be placed on an axis.""" + # A flag is not a measurement, but when it was on is worth seeing, and 0/1 + # is what a step curve is drawn from. bool is an int subclass, so it would + # fall through the isinstance below either way. + if isinstance(value, bool): + return 1.0 if value else 0.0 + if not isinstance(value, (int, float)): return None return float(value) diff --git a/backend/tests/flow/test_history.py b/backend/tests/flow/test_history.py index 9b18af0..eb8e636 100644 --- a/backend/tests/flow/test_history.py +++ b/backend/tests/flow/test_history.py @@ -45,12 +45,16 @@ def test_the_series_is_capped(): assert points[-1] == (139.0, 139.0) -def test_only_numbers_are_recorded(): +def test_numbers_and_flags_are_recorded_and_text_is_not(): + """A flag plots as the step between 0 and 1; text has no axis to sit on.""" state = MemoryState() - state.append_history({"f.text": "warm", "f.flag": True, "f.temp": 21}, ts=1.0) + state.append_history( + {"f.text": "warm", "f.flag": True, "f.off": False, "f.temp": 21}, ts=1.0 + ) assert state.history("f.text") == [] - assert state.history("f.flag") == [] + assert state.history("f.flag") == [(1.0, 1.0)] + assert state.history("f.off") == [(1.0, 0.0)] assert state.history("f.temp") == [(1.0, 21.0)] diff --git a/frontend/src/components/Common/Sparkline.tsx b/frontend/src/components/Common/Sparkline.tsx index 0bc4d98..58525a5 100644 --- a/frontend/src/components/Common/Sparkline.tsx +++ b/frontend/src/components/Common/Sparkline.tsx @@ -28,15 +28,17 @@ const READOUT_DIGITS = 4 * that never moved has no span to divide by, and one spanning decades is only * legible once the exponent is what varies. */ -function shape(points: HistoryPoint[]) { +function shape(points: HistoryPoint[], step = false) { const values = points.map((point) => point.value) const low = Math.min(...values) const high = Math.max(...values) // Logs need every reading on the same side of zero. - const logged = low > 0 && high / low >= LOG_RATIO + const logged = !step && low > 0 && high / low >= LOG_RATIO const project = (value: number) => (logged ? Math.log10(value) : value) - const floor = project(low) - const span = project(high) - floor + // A flag's axis is the two values it has, so one that was never on sits at + // the floor rather than in the middle of the box the way a flat curve does. + const floor = step ? 0 : project(low) + const span = step ? 1 : project(high) - floor const y = (value: number) => span === 0 @@ -46,10 +48,15 @@ function shape(points: HistoryPoint[]) { points.length === 1 ? 100 : (index / (points.length - 1)) * 100 const line = points - .map( - (point, index) => - `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`, - ) + .map((point, index) => { + const at = `${x(index).toFixed(2)},${y(point.value).toFixed(2)}` + if (!index) return `M${at}` + // A flag holds its last value until it changes, so it turns a corner + // rather than sloping through readings it never took. + return step + ? `L${x(index).toFixed(2)},${y(points[index - 1].value).toFixed(2)} L${at}` + : `L${at}` + }) .join(" ") return { @@ -79,6 +86,7 @@ export function Sparkline({ height = "h-8", dot = true, readout = true, + bool = false, }: { points: HistoryPoint[] /** The token the curve, its area and the dot are drawn in. */ @@ -89,9 +97,11 @@ export function Sparkline({ dot?: boolean /** The current value and the range, beside the curve. */ readout?: boolean + /** A flag: drawn as steps between 0 and 1, and read as true/false. */ + bool?: boolean }) { const id = useId() - const { low, high, line, area, end } = shape(points) + const { low, high, line, area, end } = shape(points, bool) return ( // The gap leaves the live dot room to sit on the last reading without @@ -181,10 +191,15 @@ export function Sparkline({ {readout ? (
- {si(points[points.length - 1].value, READOUT_DIGITS)} + {bool + ? points[points.length - 1].value + ? "true" + : "false" + : si(points[points.length - 1].value, READOUT_DIGITS)}
- {/* A series that never moved has no range worth repeating. */} - {low === high ? null : ( + {/* A series that never moved has no range worth repeating, and + "false–true" is not a range anyone reads. */} + {low === high || bool ? null : (
{si(low, READOUT_DIGITS)}–{si(high, READOUT_DIGITS)}
diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index 53c2b3e..6bc6c36 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -132,11 +132,11 @@ export function EdgeInspector({
{/* The same curve the node panel draws for this message: one value - says little, how it has been moving says the rest. Only for a - number, because only numbers are recorded — for anything else the - sparkline falls through to its own copy of the value, which the - popover already shows below. */} - {typeof live?.value === "number" ? ( + says little, how it has been moving says the rest. Only for what is + recorded — a number, or a flag as the steps between 0 and 1. For + anything else the sparkline falls through to its own copy of the + value, which the popover already shows below. */} + {typeof live?.value === "number" || typeof live?.value === "boolean" ? (
{ - if (typeof value !== "number" || !ts) return + // A flag is recorded as 0/1, the way the server keeps it. + if ((typeof value !== "number" && typeof value !== "boolean") || !ts) return + const reading = Number(value) setTail((previous) => previous[previous.length - 1]?.ts === ts ? previous - : [...previous, { ts, value }].slice(-WINDOW), + : [...previous, { ts, value: reading }].slice(-WINDOW), ) }, [ts, value]) @@ -127,7 +129,10 @@ export function MessageSparkline({
- +
{caption(points)}