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

This commit is contained in:
2026-11-06 14:33:25 +01:00
parent 4c15f4abe1
commit 40e60b422b
7 changed files with 259 additions and 42 deletions
+32 -1
View File
@@ -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:
+21
View File
@@ -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(
+15
View File
@@ -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
+6 -1
View File
@@ -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:
+6 -1
View File
@@ -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:
*
@@ -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<string, unknown>): ChartRange[] {
const own = ((cfg.ranges ?? []) as Partial<ChartRange>[]).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<string, unknown>): 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<Range>(
() =>
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)}
/>
<RangeRail range={range} onChange={setRange} />
<RangeRail ranges={ranges} picked={shown} onChange={setPicked} />
</div>
)
}
/** 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))}
/>
)
}
+108 -12
View File
@@ -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<ChartRange>[]
const setRange = (index: number, changes: Partial<ChartRange>) =>
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 (
<SidePanel
@@ -986,22 +999,105 @@ export function WidgetPanel({
}
/>
</div>
<div className="grid gap-2">
<FieldLabel help="Each a label, how far back in seconds (0 for the whole history) and the bucket width in seconds. Without any, the widget offers 1h to 7d.">
Ranges
</FieldLabel>
{ranges.map((range, index) => (
<div
// Position is the only identity a range row has.
key={`range-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={range.label ?? ""}
placeholder="Label"
aria-label="Range label"
onChange={(event) =>
setRange(index, { label: event.target.value })
}
/>
<Input
type="number"
className="w-24"
min={0}
placeholder="Seconds"
aria-label="Range, seconds"
value={str(range.range_s)}
onChange={(event) =>
setRange(index, {
range_s: numberOrNone(event.target.value),
})
}
/>
<Input
type="number"
className="w-24"
min={1}
placeholder="Bucket"
aria-label="Bucket, seconds"
value={str(range.interval_s)}
onChange={(event) =>
setRange(index, {
interval_s: numberOrNone(event.target.value),
})
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove range"
onClick={() =>
set({ ranges: ranges.filter((_, at) => at !== index) })
}
>
<X />
</Button>
</div>
))}
{ranges.length < MAX_RANGES ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
// A copy of the last, so the document stays one the server
// accepts while the copy is edited.
onClick={() =>
set({
ranges: [
...ranges,
{ ...(ranges[ranges.length - 1] ?? offered[start]) },
],
})
}
data-testid="add-range"
>
<Plus />
Add range
</Button>
) : null}
</div>
<div className="grid gap-1.5">
<FieldLabel help="A viewer can pick another on the widget itself.">
Range shown first
</FieldLabel>
<Select
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
onValueChange={(value) => set({ range_s: Number(value) })}
value={String(start)}
onValueChange={(value) =>
set({ range_s: offered[Number(value)].range_s })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGES.map((range) => (
{offered.map((range, index) => (
<SelectItem
key={range.label}
value={String(range.hours * 3600)}
// Position, as on the widget: two alike are still two.
key={`offered-${index}`}
value={String(index)}
>
{range.label}
</SelectItem>