From 40e60b422bed2c99019050d0290a72d974c29919 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 6 Nov 2026 14:33:25 +0100 Subject: [PATCH] A querying chart offers its own windows: an optional ranges list of {label, range_s, interval_s}, up to 8 and checked by the server, where range_s 0 asks for the whole history; the editor lists and picks them, and the refresh is capped at what a timer can hold --- backend/fluksio/flow/dashboards.py | 33 ++++- backend/tests/flow/test_dashboards.py | 21 +++ docs/interface/dashboards.md | 15 +++ frontend/src/client/schemas.gen.ts | 7 +- frontend/src/client/types.gen.ts | 7 +- .../src/components/Dashboard/ChartWidget.tsx | 98 ++++++++++---- frontend/src/components/Dashboard/panels.tsx | 120 ++++++++++++++++-- 7 files changed, 259 insertions(+), 42 deletions(-) diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 6fd370d..85510d2 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -64,6 +64,10 @@ BAR_ROWS = 8 #: client (``ui/core/counter.ts``, ``MAX_COUNTERS``). COUNTERS = 3 +#: How many windows one querying chart offers, stacked down the tile's edge. +#: Mirrored in the client (``ChartWidget.tsx``, ``MAX_RANGES``). +CHART_RANGES = 8 + #: Resolved out here on purpose: the store has a ``list`` method, which #: shadows the builtin for any annotation written inside the class. Bindings = list[dict[str, Any]] @@ -259,10 +263,15 @@ class WidgetDef(BaseModel): A chart comes in two kinds. The default reads what the engine kept for a message. One with ``source: "query"`` asks instead, and its config is ``{source, request, request_dtype: "record", message, dtype: "series", - refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to + refresh_s, range_s, ranges}``: it publishes ``{range_s, interval_s}`` to ``request`` exactly as a slider publishes a value, and draws the ``series`` a flow answers with on ``message``. + ``ranges`` lists the windows its rail offers, up to ``CHART_RANGES`` of + ``{label, range_s, interval_s}``, and ``range_s`` names the one it opens on. + Without them it offers 1h, 6h, 24h and 7d. ``range_s: 0`` asks for the + whole history, and the flow answering decides how far back that reaches. + A colour widget picks what it publishes with ``format``, because fixtures differ and a change node per tile is not the answer: @@ -448,6 +457,28 @@ class WidgetDef(BaseModel): raise ValueError( f"a querying chart's {key} must be '{want}', not '{bound}'" ) + ranges = self.config.get("ranges") or [] + if not isinstance(ranges, list) or len(ranges) > CHART_RANGES: + raise ValueError( + f"a querying chart offers a list of at most {CHART_RANGES} ranges" + ) + for entry in ranges: + window = entry if isinstance(entry, dict) else {} + label = window.get("label") + span, step = window.get("range_s"), window.get("interval_s") + # `type() is int`, because a bool is an int to isinstance. + if not ( + isinstance(label, str) + and label.strip() + and type(span) is int + and span >= 0 + and type(step) is int + and step > 0 + ): + raise ValueError( + "a querying chart's range needs a label, a whole range_s " + f"of 0 or more and a whole interval_s above 0, not {entry!r}" + ) return self if self.type == "bar" and len(self.bar_rows) > BAR_ROWS: diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 9495276..afe6ca1 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime import pytest from fluksio.flow.dashboards import ( + CHART_RANGES, DashboardDef, DashboardNotFound, DashboardStore, @@ -354,6 +355,26 @@ def test_a_querying_chart_asks_with_a_record_and_draws_a_series(): WidgetDef(id="c", type="chart", config=query_chart(request_dtype="json")) +def test_a_querying_chart_offers_its_own_ranges(): + """A few labelled windows, where ``range_s: 0`` is the whole history.""" + daily = [ + {"label": "1M", "range_s": 30 * 86400, "interval_s": 86400}, + {"label": "All", "range_s": 0, "interval_s": 86400}, + ] + WidgetDef(id="c", type="chart", config=query_chart(ranges=daily)) + + for bad in ( + [{"label": "", "range_s": 3600, "interval_s": 60}], + [{"label": "1h", "range_s": -1, "interval_s": 60}], + [{"label": "1h", "range_s": 3600, "interval_s": 0}], + [{"label": "1h", "range_s": 3600.5, "interval_s": 60}], + daily * CHART_RANGES, + "1M", + ): + with pytest.raises(ValueError, match="range"): + WidgetDef(id="c", type="chart", config=query_chart(ranges=bad)) + + def test_a_colour_widget_publishes_the_shape_its_format_names(): """The format picks the payload, so the binding is held to that one.""" wheel = WidgetDef( diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 1b00597..f9c7aba 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -188,6 +188,21 @@ Choose the chart's data source: Multi-line charts show a legend as well as different colours. +A Query chart offers 1h, 6h, 24h and 7d down its right-hand edge, and opens on +24h. Under **Ranges** you can give it windows of its own instead, up to eight, +and it opens on the one its `range_s` names or else the first. Each window +becomes the request's `range_s` and `interval_s` when picked. A `range_s` of +`0` asks for the whole history, and the answering flow decides how far back +that reaches. + +```json +"ranges": [ + {"label": "1M", "range_s": 2592000, "interval_s": 86400}, + {"label": "1Y", "range_s": 31536000, "interval_s": 86400}, + {"label": "All", "range_s": 0, "interval_s": 86400} +] +``` + !!! tip "Skip the unfinished bucket" If your query aggregates into time buckets, leave out the bucket still diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index cadd163..8ee7b12 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -5713,10 +5713,15 @@ by a schema per class, because the whole set is small and closed. A chart comes in two kinds. The default reads what the engine kept for a message. One with \`\`source: "query"\`\` asks instead, and its config is \`\`{source, request, request_dtype: "record", message, dtype: "series", -refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to +refresh_s, range_s, ranges}\`\`: it publishes \`\`{range_s, interval_s}\`\` to \`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\` a flow answers with on \`\`message\`\`. +\`\`ranges\`\` lists the windows its rail offers, up to \`\`CHART_RANGES\`\` of +\`\`{label, range_s, interval_s}\`\`, and \`\`range_s\`\` names the one it opens on. +Without them it offers 1h, 6h, 24h and 7d. \`\`range_s: 0\`\` asks for the +whole history, and the flow answering decides how far back that reaches. + A colour widget picks what it publishes with \`\`format\`\`, because fixtures differ and a change node per tile is not the answer: diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index afcfa0b..0f17b31 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1775,10 +1775,15 @@ export type WebPushKey = { * A chart comes in two kinds. The default reads what the engine kept for a * message. One with ``source: "query"`` asks instead, and its config is * ``{source, request, request_dtype: "record", message, dtype: "series", - * refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to + * refresh_s, range_s, ranges}``: it publishes ``{range_s, interval_s}`` to * ``request`` exactly as a slider publishes a value, and draws the ``series`` * a flow answers with on ``message``. * + * ``ranges`` lists the windows its rail offers, up to ``CHART_RANGES`` of + * ``{label, range_s, interval_s}``, and ``range_s`` names the one it opens on. + * Without them it offers 1h, 6h, 24h and 7d. ``range_s: 0`` asks for the + * whole history, and the flow answering decides how far back that reaches. + * * A colour widget picks what it publishes with ``format``, because fixtures * differ and a change node per tile is not the answer: * diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index a8ee6ca..b8fb6e6 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -2,11 +2,7 @@ import { useQueries, useQuery } from "@tanstack/react-query" import { useEffect, useRef, useState } from "react" import type { HistoryPoint } from "@/client" -import { - DEFAULT_RANGE, - RANGES, - type Range, -} from "@/components/Common/RangePicker" +import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" import { type LiveValue, @@ -40,9 +36,59 @@ const DEFAULT_POINTS = 300 * quarter-hour buckets asks four times an hour instead of twice a minute, for * the same picture. A slower refresh than that is honoured; a faster one only * buys the same answer again, so it is clamped. + * + * Capped at what a timer can hold, about 24 days: `setInterval` keeps its + * delay in 32 bits of milliseconds, and a longer one wraps round and fires at + * once, which would ask again as fast as the flow answers. */ -export const refreshFor = (range: Range, configured: unknown) => - Math.max(range.bucketS, Number(configured) || 0) +export const refreshFor = (range: ChartRange, configured: unknown) => + Math.min(Math.max(range.interval_s, Number(configured) || 0), 2_147_483) + +/** + * One window a querying chart offers, in the shape its request takes. + * + * `range_s: 0` asks for the whole history, and the flow answering decides how + * far back that reaches. + */ +export type ChartRange = { label: string; range_s: number; interval_s: number } + +/** How many windows one chart offers, stacked down the tile's edge. Mirrored + * server-side as `CHART_RANGES` (`app/flow/dashboards.py`). */ +export const MAX_RANGES = 8 + +/** What a chart that names no windows of its own offers. */ +const PRESETS: ChartRange[] = RANGES.map((range) => ({ + label: range.label, + range_s: range.hours * 3600, + interval_s: range.bucketS, +})) + +/** + * The windows a querying chart offers: its own `ranges`, else the presets. + * + * A row still being typed into in the editor is not a window yet. Drawn, it + * would ask for nothing and pace its timer at zero. + */ +export function rangesOf(cfg: Record): ChartRange[] { + const own = ((cfg.ranges ?? []) as Partial[]).filter( + (range): range is ChartRange => + Boolean(range.label) && + (range.range_s ?? -1) >= 0 && + (range.interval_s ?? 0) > 0, + ) + return own.length ? own : PRESETS +} + +/** Which of its windows a chart opens on: the one `range_s` names, else its + * first, or the presets' day. */ +export function startOf(cfg: Record): number { + const ranges = rangesOf(cfg) + const named = ranges.findIndex( + (range) => range.range_s === Number(cfg.range_s), + ) + if (named >= 0) return named + return ranges === PRESETS ? RANGES.indexOf(DEFAULT_RANGE) : 0 +} /** How long an unanswered request blocks an identical one. */ const INFLIGHT_MS = 15_000 @@ -353,13 +399,14 @@ function QueryChart({ widget, dashboard }: WidgetProps) { const cfg = config(widget) const request = String(cfg.request ?? "") const message = String(cfg.message ?? "") - const [range, setRange] = useState( - () => - RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ?? - DEFAULT_RANGE, - ) - const rangeS = range.hours * 3600 - const intervalS = range.bucketS + const ranges = rangesOf(cfg) + // By position, so two windows alike are still two buttons. One the editor + // has since removed falls back to the first. + const [picked, setPicked] = useState(() => startOf(cfg)) + const shown = picked < ranges.length ? picked : 0 + const range = ranges[shown] + const rangeS = range.range_s + const intervalS = range.interval_s // Follows the window: a wider one is drawn coarser, so it is worth asking // about less often. const refreshS = refreshFor(range, cfg.refresh_s) @@ -433,18 +480,20 @@ function QueryChart({ widget, dashboard }: WidgetProps) { palette={palette} {...presentation(cfg)} /> - + ) } /** The window a chart is showing, as a column beside it. */ function RangeRail({ - range, + ranges, + picked, onChange, }: { - range: Range - onChange: (range: Range) => void + ranges: ChartRange[] + picked: number + onChange: (index: number) => void }) { const { Segmented } = useUi() return ( @@ -452,16 +501,11 @@ function RangeRail({ orientation="vertical" testId="range-picker" label="Time range" - value={String(range.hours)} - options={RANGES.map( - (entry) => [String(entry.hours), entry.label] as const, + value={String(picked)} + options={ranges.map( + (range, index) => [String(index), range.label] as const, )} - onChange={(hours) => - onChange( - RANGES.find((entry) => String(entry.hours) === hours) ?? - DEFAULT_RANGE, - ) - } + onChange={(index) => onChange(Number(index))} /> ) } diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index b93b451..d47b04a 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -3,7 +3,6 @@ import { Ban, Check, ChevronDown, Plus, RotateCw, X } from "lucide-react" import { useState } from "react" import type { MessageInfo, SettingDef, WidgetDef } from "@/client" -import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { screensOf } from "@/components/Devices/ports" import { devicesQueryOptions } from "@/components/Devices/queries" import { RenameDialog } from "@/components/Flow/RenameDialog" @@ -52,7 +51,14 @@ import { import { Switch } from "@/components/ui/switch" import useAuth from "@/hooks/useAuth" import { cn } from "@/lib/utils" -import { MAX_SERIES, refreshFor } from "./ChartWidget" +import { + type ChartRange, + MAX_RANGES, + MAX_SERIES, + rangesOf, + refreshFor, + startOf, +} from "./ChartWidget" import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget" import { CANVAS_PRESETS, @@ -569,13 +575,20 @@ export function WidgetPanel({ }) const querying = cfg.source === "query" const pinnedRuns = cfg.source === "runs" + // A querying chart's windows, read raw like a grid's presses: `rangesOf` + // drops a row until it is complete, and the rail offers what it keeps. + const ranges = (cfg.ranges ?? []) as Partial[] + const setRange = (index: number, changes: Partial) => + set({ + ranges: ranges.map((other, at) => + at === index ? { ...other, ...changes } : other, + ), + }) + const offered = rangesOf(cfg) + const start = startOf(cfg) // What this chart would refresh at with nothing configured. The viewer can // pick another window on the widget, which moves it. - const paced = refreshFor( - RANGES.find((range) => range.hours * 3600 === Number(cfg.range_s)) ?? - DEFAULT_RANGE, - 0, - ) + const paced = refreshFor(offered[start], 0) return ( +
+ + Ranges + + {ranges.map((range, index) => ( +
+ + setRange(index, { label: event.target.value }) + } + /> + + setRange(index, { + range_s: numberOrNone(event.target.value), + }) + } + /> + + setRange(index, { + interval_s: numberOrNone(event.target.value), + }) + } + /> + +
+ ))} + {ranges.length < MAX_RANGES ? ( + + ) : null} +
Range shown first