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:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user