From 44359f88cf61c59add9d20f5180346f8c2e0ade1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 17 Aug 2026 13:41:02 +0200 Subject: [PATCH 1/4] feat(dashboards): configurable auto-refresh interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboards only refreshed when a viewer clicked Reload, so a board left on a wall monitor went stale. Add a Grafana-style cadence dropdown next to Reload, on both the signed-in board and the share page. Nearly all the machinery already existed with no callers: the `refreshIntervalSeconds` document field and its closed literal set, the v2 wire field, the version-history label, `updateDashboardRefreshInterval`, and `PageRefreshProvider`'s `autoRefreshMs`/`autoRefreshPaused` props. This wires them up and adds the control. Resolution is `?refresh=` (per viewer) → the board's saved default → off. Picking a cadence always writes the param, so a read-only viewer can start or silence auto-refresh without dirtying the document; in edit mode it additionally saves the board default, the one path that cuts a version. Ticks pause while editing or previewing a version, and the existing hidden-tab guard keeps an idle board from polling. `refresh` is written as a number so URLs read `?refresh=30` rather than the `?refresh="30"` TanStack emits to preserve string-ness; the schema accepts both, and anything outside the literal set falls back instead of failing the route. On the share page the resolved window was memoized immutably, so a relative share would have re-fetched an identical window forever. It now re-resolves unsnapped on each tick, matching the signed-in board. Single -widget shares also keep the cadence through redaction, which previously only the whole-board branch carried. --- .../toolbar/dashboard-toolbar.tsx | 16 +++- .../page-refresh-context.test.tsx | 82 +++++++++++++++++++ .../refresh-interval-picker.tsx | 74 +++++++++++++++++ .../dashboard-controls/search-params.test.ts | 46 ++++++++++- .../lib/dashboard-controls/search-params.ts | 69 ++++++++++++++-- .../src/routes/dashboards/$dashboardId.tsx | 56 ++++++++++++- apps/web/src/routes/share/$token.tsx | 79 ++++++++++++++++-- packages/widgets/src/dashboard/redact.test.ts | 7 ++ packages/widgets/src/dashboard/redact.ts | 6 ++ 9 files changed, 413 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/time-range-picker/refresh-interval-picker.tsx diff --git a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx index 595a52c5a..153dd5b5b 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx @@ -23,6 +23,7 @@ import { } from "@maple/ui/components/ui/dropdown-menu" import { TimeRangePicker } from "@/components/time-range-picker/time-range-picker" import { ReloadControls } from "@/components/time-range-picker/reload-controls" +import { RefreshIntervalPicker } from "@/components/time-range-picker/refresh-interval-picker" import { VariableSelects } from "@/components/dashboard-builder/toolbar/variable-selects" import { useDashboardTimeRange } from "@/components/dashboard-builder/dashboard-providers" import { useDashboardActions } from "@/components/dashboard-builder/dashboard-actions-context" @@ -33,12 +34,16 @@ import { ShareDashboardDialog } from "@/components/dashboard-builder/toolbar/sha import { collectTags } from "@/components/dashboard-builder/list/dashboard-summary" import { useDashboardStore } from "@/hooks/use-dashboard-store" import type { Dashboard } from "@/components/dashboard-builder/types" +import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" interface DashboardToolbarProps { dashboard: Dashboard onToggleEdit: () => void onAddWidget: () => void onOpenHistory?: () => void + /** Effective cadence for this viewer: `?refresh=` if set, else the board's default. */ + refreshIntervalSeconds: DashboardRefreshIntervalSeconds + onRefreshIntervalChange: (value: DashboardRefreshIntervalSeconds) => void } export function DashboardToolbar({ @@ -46,6 +51,8 @@ export function DashboardToolbar({ onToggleEdit, onAddWidget, onOpenHistory, + refreshIntervalSeconds, + onRefreshIntervalChange, }: DashboardToolbarProps) { const { mode, readOnly, autoLayoutWidgets, addSection } = useDashboardActions() const { @@ -94,7 +101,14 @@ export function DashboardToolbar({ }} /> - +
+ + +
{/* Labels collapse to icon-only on a narrow canvas; `aria-label` diff --git a/apps/web/src/components/time-range-picker/page-refresh-context.test.tsx b/apps/web/src/components/time-range-picker/page-refresh-context.test.tsx index 37c4c9346..9fc5c0c40 100644 --- a/apps/web/src/components/time-range-picker/page-refresh-context.test.tsx +++ b/apps/web/src/components/time-range-picker/page-refresh-context.test.tsx @@ -6,6 +6,7 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" import { useState, type ReactNode } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useMountEffect } from "@/hooks/use-mount-effect" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { @@ -136,3 +137,84 @@ describe("page refresh controller", () => { expect(onRelativeRangeRefresh).not.toHaveBeenCalled() }) }) + +/** + * Stands in for a widget: every tile subscribes through + * `useRefreshableAtomValue`, so counting listener calls counts canvas refreshes. + */ +function Subscriber({ onReload }: { onReload: () => void }) { + const { subscribeReload } = usePageRefreshContext() + // Mount-scoped rather than render-time: subscribing during render would + // double-add under StrictMode with no matching cleanup. + useMountEffect(() => subscribeReload(onReload)) + return null +} + +function AutoRefreshHarness({ + onReload, + autoRefreshMs, + autoRefreshPaused, +}: { + onReload: () => void + autoRefreshMs?: number + autoRefreshPaused?: boolean +}) { + return ( + + + + ) +} + +describe("page refresh auto-refresh", () => { + beforeEach(() => vi.useFakeTimers()) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("fans a tick out to every subscriber on the configured cadence", () => { + const onReload = vi.fn() + render() + + act(() => vi.advanceTimersByTime(5_000)) + expect(onReload).toHaveBeenCalledTimes(1) + + act(() => vi.advanceTimersByTime(10_000)) + expect(onReload).toHaveBeenCalledTimes(3) + }) + + // `0`/absent is the off sentinel every non-dashboard page relies on; it must + // register no interval at all rather than a `setInterval(…, 0)` hot loop. + it("registers no timer when the cadence is absent or zero", () => { + const onReload = vi.fn() + render() + act(() => vi.advanceTimersByTime(60_000)) + expect(onReload).not.toHaveBeenCalled() + + cleanup() + render() + act(() => vi.advanceTimersByTime(60_000)) + expect(onReload).not.toHaveBeenCalled() + }) + + // Editing a dashboard or previewing a version suspends the timer without + // forgetting the cadence, so leaving that state resumes at the same interval. + it("suspends while paused and resumes on the same cadence", () => { + const onReload = vi.fn() + const view = render( + , + ) + + act(() => vi.advanceTimersByTime(20_000)) + expect(onReload).not.toHaveBeenCalled() + + view.rerender( + , + ) + act(() => vi.advanceTimersByTime(5_000)) + expect(onReload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx b/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx new file mode 100644 index 000000000..491eb9d29 --- /dev/null +++ b/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx @@ -0,0 +1,74 @@ +import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu" + +import { ChevronDownIcon, ClockIcon } from "@/components/icons" +import { REFRESH_INTERVAL_OPTIONS } from "@/lib/dashboard-controls/search-params" + +/** + * `0` is the off sentinel, so it has no duration to render — the trigger falls + * back to the icon alone rather than showing "0s". + */ +export function formatRefreshInterval(seconds: DashboardRefreshIntervalSeconds): string { + if (seconds === 0) return "Off" + return seconds < 60 ? `${seconds}s` : `${seconds / 60}m` +} + +interface RefreshIntervalPickerProps { + value: DashboardRefreshIntervalSeconds + onChange: (value: DashboardRefreshIntervalSeconds) => void + /** + * The dashboard's stored cadence, when the caller has one. Rendered as a hint + * on the matching row so a viewer overriding via `?refresh=` can see what the + * board itself is set to. + */ + savedDefault?: DashboardRefreshIntervalSeconds +} + +export function RefreshIntervalPicker({ value, onChange, savedDefault }: RefreshIntervalPickerProps) { + const isOn = value > 0 + + return ( + + + } + > + + {isOn && {formatRefreshInterval(value)}} + + + + {/* Values are strings on the wire because Base UI's RadioGroup compares + by identity and the caller round-trips them through the URL. */} + onChange(Number(next) as DashboardRefreshIntervalSeconds)} + > + {REFRESH_INTERVAL_OPTIONS.map((seconds) => ( + + {formatRefreshInterval(seconds)} + {savedDefault === seconds && ( + default + )} + + ))} + + + + ) +} diff --git a/apps/web/src/lib/dashboard-controls/search-params.test.ts b/apps/web/src/lib/dashboard-controls/search-params.test.ts index 82b731cd3..a6391f53e 100644 --- a/apps/web/src/lib/dashboard-controls/search-params.test.ts +++ b/apps/web/src/lib/dashboard-controls/search-params.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { pickDashboardControlParams } from "./search-params" +import { pickDashboardControlParams, resolveRefreshIntervalSeconds } from "./search-params" describe("pickDashboardControlParams", () => { it("retains every `var-*` selection", () => { @@ -21,6 +21,12 @@ describe("pickDashboardControlParams", () => { expect(pickDashboardControlParams(search)).toEqual(search) }) + // The one numeric control param, so the string-only rule above would drop it. + it("retains a numeric `refresh`", () => { + expect(pickDashboardControlParams({ refresh: 30 })).toEqual({ refresh: 30 }) + expect(pickDashboardControlParams({ refresh: 0 })).toEqual({ refresh: 0 }) + }) + // `mode` is set explicitly by whichever caller wants edit mode. Folding it in // here would make every navigation sticky in edit — including the one that // exists to leave it. @@ -59,3 +65,41 @@ describe("pickDashboardControlParams", () => { expect(picked).not.toBe(search) }) }) + +describe("resolveRefreshIntervalSeconds", () => { + it("prefers the URL override over the board's saved default", () => { + expect(resolveRefreshIntervalSeconds("30", 300)).toBe(30) + }) + + it("falls back to the saved default when there is no override", () => { + expect(resolveRefreshIntervalSeconds(undefined, 60)).toBe(60) + }) + + it("is off when neither is set", () => { + expect(resolveRefreshIntervalSeconds(undefined, undefined)).toBe(0) + }) + + // `0` is a real value, not "absent": a viewer must be able to silence a board + // that auto-refreshes for everyone else. + it("lets `?refresh=0` turn off a board whose default is on", () => { + expect(resolveRefreshIntervalSeconds("0", 60)).toBe(0) + }) + + // TanStack JSON-parses search values, so the same URL can hand us either form. + it("accepts a number as readily as a string", () => { + expect(resolveRefreshIntervalSeconds(10, undefined)).toBe(10) + }) + + // A hand-edited URL must not ask the browser to re-query every 100ms, so + // anything outside the closed set falls through to the saved default. + it("ignores an override outside the allowed set", () => { + expect(resolveRefreshIntervalSeconds("1", 60)).toBe(60) + expect(resolveRefreshIntervalSeconds("abc", 60)).toBe(60) + expect(resolveRefreshIntervalSeconds("", 60)).toBe(60) + expect(resolveRefreshIntervalSeconds(null, 60)).toBe(60) + }) + + it("ignores a stored value outside the allowed set", () => { + expect(resolveRefreshIntervalSeconds(undefined, 7)).toBe(0) + }) +}) diff --git a/apps/web/src/lib/dashboard-controls/search-params.ts b/apps/web/src/lib/dashboard-controls/search-params.ts index dbf5ffe01..68d63aad1 100644 --- a/apps/web/src/lib/dashboard-controls/search-params.ts +++ b/apps/web/src/lib/dashboard-controls/search-params.ts @@ -1,13 +1,14 @@ import { Schema } from "effect" +import { DashboardRefreshIntervalSeconds } from "@maple/domain/http" // Cross-widget dashboard controls live in the URL so a view is shareable and // deep-linkable. Two families share that space: // // `var-` dashboard-variable selections (Grafana-style) -// `filter`, `collapsed`, `expanded`, `tab`, `widget` +// `filter`, `collapsed`, `expanded`, `tab`, `widget`, `refresh` // per-viewer view state: the free-text filter clause, section -// collapse overrides, the active tab per section, and a tile -// deep link +// collapse overrides, the active tab per section, a tile +// deep link, and the auto-refresh cadence // // This module is the one owner of both. Every `navigate()` on a dashboard route // rebuilds search from `pickDashboardControlParams`, so a param that is not @@ -32,9 +33,9 @@ export const variableSearchRest = Schema.Record( * Per-viewer view state. `Schema.optional` rather than `optionalKey` because * these are search params, where `undefined` is a real JS value. * - * All five are flat strings rather than JSON blobs: these end up in URLs people - * paste to each other, and a hand-editable `?tab=overview:latency` is both - * readable and impossible to fail parsing. + * All of them are flat scalars rather than JSON blobs: these end up in URLs + * people paste to each other, and a hand-editable `?tab=overview:latency` is + * both readable and impossible to fail parsing. */ export const dashboardViewParamsSchema = { /** Free-text dashboard filter clause, in the `@maple/domain` where-clause grammar. */ @@ -47,6 +48,17 @@ export const dashboardViewParamsSchema = { tab: Schema.optional(Schema.String), /** Widget deep link: expand the owning section, switch to its tab, scroll to it, flash it. */ widget: Schema.optional(Schema.String), + /** + * Auto-refresh cadence in seconds, overriding the dashboard's saved default + * for this viewer only. `0` is a real value meaning "explicitly off", so a + * viewer can silence a board that auto-refreshes for everyone else. + * + * The only numeric control param: written as a number so the URL reads + * `?refresh=30` rather than the `?refresh="30"` TanStack emits to preserve + * string-ness. The string arm keeps a hand-edited quoted form working, and + * anything unparseable falls back rather than failing the route. + */ + refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), } export interface DashboardViewParams { @@ -55,6 +67,7 @@ export interface DashboardViewParams { expanded?: string tab?: string widget?: string + refresh?: number | string } export type DashboardControlParams = VariableSearchParams & DashboardViewParams @@ -78,7 +91,43 @@ export function variableValuesFromSearch(search: Record): Recor return values } -const VIEW_PARAM_KEYS = ["filter", "collapsed", "expanded", "tab", "widget"] as const +const VIEW_PARAM_KEYS = ["filter", "collapsed", "expanded", "tab", "widget", "refresh"] as const + +/** Ordered cadence options for the picker, straight off the schema's closed set. */ +export const REFRESH_INTERVAL_OPTIONS: ReadonlyArray = + DashboardRefreshIntervalSeconds.literals + +const REFRESH_INTERVAL_VALUES = new Set(REFRESH_INTERVAL_OPTIONS) + +/** A cadence if the value is a member of the closed set, `undefined` otherwise. */ +export function parseRefreshIntervalSeconds(value: unknown): DashboardRefreshIntervalSeconds | undefined { + // TanStack JSON-parses search values, so `?refresh=30` can arrive as either a + // number or a string depending on how it was written. An empty `?refresh=` is + // treated as absent rather than as `Number("") === 0`, which would silently + // read a truncated URL as "auto-refresh off". + const seconds = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : Number.NaN + return REFRESH_INTERVAL_VALUES.has(seconds) ? (seconds as DashboardRefreshIntervalSeconds) : undefined +} + +/** + * The cadence this viewer should actually poll at: `?refresh=` wins, then the + * dashboard's saved default, then off. + * + * A hand-edited `?refresh=1` or `?refresh=abc` falls back rather than throwing — + * same posture as the non-string view params above, and the reason the closed + * literal set exists at all (nobody gets to ask for a 100ms re-query). + */ +export function resolveRefreshIntervalSeconds( + param: unknown, + stored: number | undefined, +): DashboardRefreshIntervalSeconds { + return parseRefreshIntervalSeconds(param) ?? parseRefreshIntervalSeconds(stored) ?? 0 +} /** * Pick every cross-widget control param out of an arbitrary search object. @@ -107,5 +156,11 @@ export function pickDashboardControlParams(search: Record): Das } } + // `refresh` is the one numeric control param (`?refresh=30`), so the + // string-only loop above would drop it. An out-of-set number survives the + // pick and is rejected later by `resolveRefreshIntervalSeconds`, which keeps + // this function's job to "carry the control params forward". + if (typeof search.refresh === "number") params.refresh = search.refresh + return params } diff --git a/apps/web/src/routes/dashboards/$dashboardId.tsx b/apps/web/src/routes/dashboards/$dashboardId.tsx index e5cdec42d..9de6a9e6b 100644 --- a/apps/web/src/routes/dashboards/$dashboardId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId.tsx @@ -23,6 +23,7 @@ import { VARIABLE_PARAM_PREFIX, dashboardViewParamsSchema, pickDashboardControlParams, + resolveRefreshIntervalSeconds, variableSearchRest, variableValuesFromSearch, } from "@/lib/dashboard-controls/search-params" @@ -40,7 +41,7 @@ import { historyPanelOpenAtom, previewedVersionAtom } from "@/atoms/dashboard-hi import { useDashboardVersions } from "@/components/dashboard-builder/history/use-dashboard-history" import { Result } from "@/lib/effect-atom" import { useMemo, useState, type ReactNode } from "react" -import type { SectionTarget } from "@maple/domain/http" +import type { DashboardRefreshIntervalSeconds, SectionTarget } from "@maple/domain/http" // Module-level atoms — singleton (only one dashboard page visible at a time) const chartPickerOpenAtom = Atom.make(false) @@ -67,12 +68,28 @@ export const Route = createFileRoute("/dashboards/$dashboardId")({ validateSearch: Schema.toStandardSchemaV1(dashboardViewSearchSchema), }) -function DashboardRefreshBridge({ children }: { children: ReactNode }) { +function DashboardRefreshBridge({ + children, + refreshIntervalSeconds, + paused, +}: { + children: ReactNode + refreshIntervalSeconds: DashboardRefreshIntervalSeconds + paused: boolean +}) { const { state: { timeRange }, } = useDashboardTimeRange() const timePreset = timeRange.type === "relative" ? timeRange.value : undefined - return {children} + return ( + + {children} + + ) } function DashboardViewPage() { @@ -91,6 +108,7 @@ function DashboardViewPage() { persistenceError, updateDashboard, updateDashboardTimeRange, + updateDashboardRefreshInterval, addWidget, cloneWidget, removeWidget, @@ -137,6 +155,31 @@ function DashboardViewPage() { }) } + // `?refresh=` is per-viewer and wins over the board's saved cadence, so a + // read-only viewer can start (or silence) auto-refresh without touching the + // document. Picking one always writes the param; in edit mode it *also* + // becomes the dashboard's default, which is the only path that cuts a version. + const refreshIntervalSeconds = resolveRefreshIntervalSeconds( + search.refresh, + activeDashboard?.refreshIntervalSeconds, + ) + + const handleRefreshIntervalChange = (next: DashboardRefreshIntervalSeconds) => { + if (mode === "edit" && !readOnly && !isPreviewing) { + updateDashboardRefreshInterval(dashboardId, next) + } + navigate({ + to: "/dashboards/$dashboardId", + params: { dashboardId }, + replace: true, + search: (prev) => ({ + ...pickDashboardControlParams(prev), + ...(prev.mode === "edit" ? { mode: "edit" as const } : undefined), + refresh: next, + }), + }) + } + const urlVariableValues = useMemo(() => variableValuesFromSearch(search), [search]) const handleVariableChange = (name: string, value: string) => { @@ -295,7 +338,10 @@ function DashboardViewPage() { moveWidgetToSection, }} > - + setChartPickerOpen(true)} onOpenHistory={openHistory} + refreshIntervalSeconds={refreshIntervalSeconds} + onRefreshIntervalChange={handleRefreshIntervalChange} /> diff --git a/apps/web/src/routes/share/$token.tsx b/apps/web/src/routes/share/$token.tsx index 3dfa219ec..72af4f8f3 100644 --- a/apps/web/src/routes/share/$token.tsx +++ b/apps/web/src/routes/share/$token.tsx @@ -30,7 +30,16 @@ import { type ShareWidgetOptionsReporter, } from "@/components/share/shared-widget-renderer" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" -import { variableSearchRest, variableValuesFromSearch } from "@/lib/dashboard-controls/search-params" +import { + parseRefreshIntervalSeconds, + resolveRefreshIntervalSeconds, + variableSearchRest, + variableValuesFromSearch, +} from "@/lib/dashboard-controls/search-params" +import { RefreshIntervalPicker } from "@/components/time-range-picker/refresh-interval-picker" +import { useIntervalRefresh } from "@/hooks/use-interval-refresh" +import { ArrowRotateAnticlockwiseIcon } from "@/components/icons" +import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" import { formatTimeRangeDisplay, presetLabel } from "@/lib/time-utils" import { shareTimeRange, @@ -55,6 +64,12 @@ const ShareSearch = Schema.StructWithRest( embed: Schema.optional(Schema.Boolean), from: Schema.optional(Schema.String), to: Schema.optional(Schema.String), + /** + * Auto-refresh cadence in seconds for this URL, overriding the board's + * stored default. A share has nowhere to persist a viewer's choice, so + * this is the only place the picker's selection lives. + */ + refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), }), // `?var-=` selections, exactly as on the dashboard route, so a deep // link into a board and into its share pick the same values. The server @@ -87,6 +102,7 @@ const DEFAULT_SHARE_TIME_RANGE = { type: "relative", value: "1h" } as const const resolveShareWindow = ( search: { readonly from?: string; readonly to?: string }, stored: unknown, + { snap }: { snap: boolean } = { snap: true }, ): ShareWindow | null => { if (search.from !== undefined && search.to !== undefined) { return { @@ -95,7 +111,7 @@ const resolveShareWindow = ( } } const timeRange = shareTimeRange(stored) ?? DEFAULT_SHARE_TIME_RANGE - const resolved = resolveTimeRange(timeRange) + const resolved = resolveTimeRange(timeRange, { snap }) if (resolved === null) return null return { timeRange: resolved, @@ -165,6 +181,7 @@ function SharePageContent({ isSignedIn }: { isSignedIn: boolean }) { from={search.from} to={search.to} search={search} + refreshParam={search.refresh} signedIn={isSignedIn} embed={search.embed === true} /> @@ -355,6 +372,7 @@ function ShareBody({ from, to, search, + refreshParam, signedIn, embed, }: { @@ -363,14 +381,23 @@ function ShareBody({ from: string | undefined to: string | undefined search: Record + refreshParam: number | string | undefined signedIn: boolean embed: boolean }) { - // Recomputed only when the URL or the resolved board changes: re-resolving - // a relative preset on every render would re-key the fetch effect forever. + const navigate = Route.useNavigate() + // Bumped by a manual reload or an auto-refresh tick. It re-resolves the + // window *unsnapped* so a relative share actually advances to "now" — the + // signed-in board does the same at dashboard-time-range-atoms.ts:77 — and it + // re-requests every tile even when the window is absolute and unchanged. + const [refreshTick, setRefreshTick] = useState(0) + + // Recomputed only when the URL, the resolved board, or a refresh changes: + // re-resolving a relative preset on every render would re-key the fetch + // effect forever. const window = useMemo( - () => resolveShareWindow({ from, to }, share.dashboard.timeRange), - [from, to, share.dashboard.timeRange], + () => resolveShareWindow({ from, to }, share.dashboard.timeRange, { snap: refreshTick === 0 }), + [from, to, share.dashboard.timeRange, refreshTick], ) // Only what the URL selects; the server runs the board's own ladder // (default → All → first option) for everything else, so an unset variable @@ -393,7 +420,7 @@ function ShareBody({ () => share.dashboard.widgets.filter((widget) => options[widget.id] !== undefined), [share.dashboard.widgets, options], ) - const { states, variables } = useShareWidgetData( + const { states, variables, refresh } = useShareWidgetData( token, reportedWidgets, window?.timeRange ?? EMPTY_WINDOW, @@ -403,6 +430,24 @@ function ShareBody({ options, ) + const handleRefresh = useCallback(() => { + setRefreshTick((tick) => tick + 1) + refresh() + }, [refresh]) + + // `?refresh=` is the only source here: a share has no signed-in viewer to + // save a default for, so the picker overrides the board's stored cadence for + // this URL and nothing more. + const storedRefreshInterval = parseRefreshIntervalSeconds(share.dashboard.refreshIntervalSeconds) + const refreshIntervalSeconds = resolveRefreshIntervalSeconds(refreshParam, storedRefreshInterval) + const handleRefreshIntervalChange = (next: DashboardRefreshIntervalSeconds) => { + navigate({ replace: true, search: (prev) => ({ ...prev, refresh: next }) }) + } + useIntervalRefresh(handleRefresh, { + intervalMs: refreshIntervalSeconds * 1000, + enabled: refreshIntervalSeconds > 0 && window !== null, + }) + if (window === null) { return ( {/* The one thing a viewer needs to compare this page with the board it shares: which window they are looking at. */} + {/* An embed keeps auto-refreshing on whatever the URL or the board asked + for, but draws no control for it: it lives inside someone else's + document, where our chrome would be furniture. */} {embed ? null : ( -
- {window.label} +
+
+ {window.label} +
+
+ + +
)} {/* Titles interpolate `$service` and friends with the values the server diff --git a/packages/widgets/src/dashboard/redact.test.ts b/packages/widgets/src/dashboard/redact.test.ts index 3314d1b37..48f7e3034 100644 --- a/packages/widgets/src/dashboard/redact.test.ts +++ b/packages/widgets/src/dashboard/redact.test.ts @@ -157,6 +157,13 @@ describe("redactForShare", () => { expect(redacted?.widgets[0]?.tabId).toBeUndefined() }) + // A single tile auto-refreshes on the cadence of the board it came from — + // otherwise a chart link goes stale on a wall while the board it was cut from + // keeps updating. + it("keeps the auto-refresh cadence on a single-chart share", () => { + expect(redactForShare(document, "w-query")?.refreshIntervalSeconds).toBe(60) + }) + it("narrows a single-chart share's variables to the ones that chart uses", () => { // The board's variable list is the board's. A chart link that shipped it // whole would publish names, labels, option values and attribute keys diff --git a/packages/widgets/src/dashboard/redact.ts b/packages/widgets/src/dashboard/redact.ts index 6fabb1028..b9c0ec150 100644 --- a/packages/widgets/src/dashboard/redact.ts +++ b/packages/widgets/src/dashboard/redact.ts @@ -211,6 +211,12 @@ export function redactForShare( if (document.variables !== undefined) { redacted = { ...redacted, variables: variablesForWidget(document.variables, widget) } } + // A single-tile share auto-refreshes on the same cadence as the board it came + // from. The value is a literal out of a closed set, so it leaks nothing the + // viewer could not infer from watching the tile update on its own. + if (document.refreshIntervalSeconds !== undefined) { + redacted = { ...redacted, refreshIntervalSeconds: document.refreshIntervalSeconds } + } return redacted } From 95c4648ab3e69d92ad4ee522cd1c7cc68089fa03 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 17 Aug 2026 17:33:53 +0200 Subject: [PATCH 2/4] fix(dashboards): make the auto-refresh control read as auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger was a bare clock icon, sitting next to a time-range picker that carries a clock icon of its own — so it read as a second time control rather than as auto-refresh. Three changes, all about naming the thing: - The glyph is now the same reload arrow as the Reload button beside it, so the pair reads as one control: reload now, or reload every N. - The trigger is always labelled "Auto", not just when a cadence is set. An icon-only button left the viewer guessing what it did. - The menu carries an "Auto-refresh" group label, so the control explains itself on open — including for screen readers, and for a viewer who arrived straight from a `?refresh=` link. Adds a render test. Base UI's GroupLabel throws production error #31 outside a Group — a full error-boundary crash, and only reachable by actually opening the menu — so a menu with a label needs a render test rather than a type check. --- .../refresh-interval-picker.test.tsx | 56 +++++++++++++++++++ .../refresh-interval-picker.tsx | 30 +++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx diff --git a/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx b/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx new file mode 100644 index 000000000..4137162f5 --- /dev/null +++ b/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { RefreshIntervalPicker } from "./refresh-interval-picker" + +afterEach(cleanup) + +const openMenu = () => fireEvent.click(screen.getByRole("button")) + +describe("RefreshIntervalPicker", () => { + // The trigger sits beside a time-range picker that also carries a clock-ish + // icon, so an icon-only control read as a second time-range control. The word + // is what makes it legible; if it goes, the ambiguity comes back. + it("names itself in the trigger whether on or off", () => { + const { rerender } = render( {}} />) + expect(screen.getByRole("button").textContent).toContain("Auto") + expect(screen.getByRole("button", { name: "Auto-refresh off" })).toBeDefined() + + rerender( {}} />) + expect(screen.getByRole("button").textContent).toContain("30s") + expect(screen.getByRole("button", { name: "Auto-refresh every 30s" })).toBeDefined() + }) + + // Base UI's GroupLabel throws production error #31 outside a Group, which is a + // full error-boundary crash rather than a warning — and only opening the menu + // exercises it. A type check would not catch this. + it("opens without throwing and names the group", () => { + render( {}} />) + + openMenu() + expect(screen.getByText("Auto-refresh")).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "Off" })).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "15m" })).toBeDefined() + }) + + // The URL and the document both hold numbers; the radio group only speaks + // strings, so this is the seam where a stray string would leak outward. + it("reports the chosen cadence as a number", () => { + const onChange = vi.fn() + render() + + openMenu() + fireEvent.click(screen.getByRole("menuitemradio", { name: "5s" })) + expect(onChange).toHaveBeenCalledWith(5) + }) + + it("marks the board's saved cadence so a `?refresh=` override stays legible", () => { + render( {}} savedDefault={60} />) + + openMenu() + expect(screen.getByRole("menuitemradio", { name: /1m\s*default/ })).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "5s" }).getAttribute("aria-checked")).toBe("true") + }) +}) diff --git a/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx b/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx index 491eb9d29..5364fb824 100644 --- a/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx +++ b/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx @@ -3,18 +3,16 @@ import { Button } from "@maple/ui/components/ui/button" import { DropdownMenu, DropdownMenuContent, + DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@maple/ui/components/ui/dropdown-menu" -import { ChevronDownIcon, ClockIcon } from "@/components/icons" +import { ArrowRotateAnticlockwiseIcon, ChevronDownIcon } from "@/components/icons" import { REFRESH_INTERVAL_OPTIONS } from "@/lib/dashboard-controls/search-params" -/** - * `0` is the off sentinel, so it has no duration to render — the trigger falls - * back to the icon alone rather than showing "0s". - */ +/** `0` is the off sentinel, so it has no duration to render. */ export function formatRefreshInterval(seconds: DashboardRefreshIntervalSeconds): string { if (seconds === 0) return "Off" return seconds < 60 ? `${seconds}s` : `${seconds / 60}m` @@ -42,23 +40,39 @@ export function RefreshIntervalPicker({ value, onChange, savedDefault }: Refresh type="button" variant="outline" size="sm" + title={ + isOn + ? `Auto-refreshing every ${formatRefreshInterval(value)}` + : "Auto-refresh is off" + } aria-label={ isOn ? `Auto-refresh every ${formatRefreshInterval(value)}` : "Auto-refresh off" } /> } > - - {isOn && {formatRefreshInterval(value)}} + {/* Same reload glyph as the button beside it, so the pair reads as one + control: reload now, or reload every N. A clock here read as a + second time-range picker next to the real one. */} + + {/* Always labelled. An icon-only trigger left the viewer guessing what + it did, which is the whole point of the word "Auto". */} + Auto + {isOn && {formatRefreshInterval(value)}} - + {/* Values are strings on the wire because Base UI's RadioGroup compares by identity and the caller round-trips them through the URL. */} onChange(Number(next) as DashboardRefreshIntervalSeconds)} > + {/* Names the group for screen readers as well as sighted viewers, so + the menu is self-explanatory even reached straight from the URL. + Safe inside a RadioGroup: the label only self-wraps in a Group + when nothing above it provided the context. */} + Auto-refresh {REFRESH_INTERVAL_OPTIONS.map((seconds) => ( {formatRefreshInterval(seconds)} From 15b4eb13a724244f1096b78570f10e13f1ddf080 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 17 Aug 2026 17:39:02 +0200 Subject: [PATCH 3/4] refactor(dashboards): join reload and cadence into one split button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reload and the cadence dropdown were two separate outline buttons sitting next to each other, which is what made the cadence half ambiguous — an adjacent control is just another control, and beside the time-range picker it read as a second time control. Grafana attaches them, and attachment is what carries the meaning: "30s" welded to "Reload" can only be read one way. So the two halves are now one split button sharing a seam, and the cadence half is back to showing just the interval (or "Off") rather than explaining itself with a word. `RefreshControls` takes the reload action as a prop and `PageRefreshControls` binds it to the page refresh context, because the share page drives its own refresh and has no such context. `ReloadControls` stays as-is for traces, metrics, service-map and infra, which have no cadence to offer. --- .../toolbar/dashboard-toolbar.tsx | 16 +-- .../refresh-controls.test.tsx | 72 ++++++++++ .../time-range-picker/refresh-controls.tsx | 135 ++++++++++++++++++ .../refresh-interval-picker.test.tsx | 56 -------- .../refresh-interval-picker.tsx | 88 ------------ apps/web/src/routes/share/$token.tsx | 20 +-- 6 files changed, 220 insertions(+), 167 deletions(-) create mode 100644 apps/web/src/components/time-range-picker/refresh-controls.test.tsx create mode 100644 apps/web/src/components/time-range-picker/refresh-controls.tsx delete mode 100644 apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx delete mode 100644 apps/web/src/components/time-range-picker/refresh-interval-picker.tsx diff --git a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx index 153dd5b5b..06c15b767 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx @@ -22,8 +22,7 @@ import { DropdownMenuSeparator, } from "@maple/ui/components/ui/dropdown-menu" import { TimeRangePicker } from "@/components/time-range-picker/time-range-picker" -import { ReloadControls } from "@/components/time-range-picker/reload-controls" -import { RefreshIntervalPicker } from "@/components/time-range-picker/refresh-interval-picker" +import { PageRefreshControls } from "@/components/time-range-picker/refresh-controls" import { VariableSelects } from "@/components/dashboard-builder/toolbar/variable-selects" import { useDashboardTimeRange } from "@/components/dashboard-builder/dashboard-providers" import { useDashboardActions } from "@/components/dashboard-builder/dashboard-actions-context" @@ -101,14 +100,11 @@ export function DashboardToolbar({ }} /> -
- - -
+
{/* Labels collapse to icon-only on a narrow canvas; `aria-label` diff --git a/apps/web/src/components/time-range-picker/refresh-controls.test.tsx b/apps/web/src/components/time-range-picker/refresh-controls.test.tsx new file mode 100644 index 000000000..15a8280d1 --- /dev/null +++ b/apps/web/src/components/time-range-picker/refresh-controls.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { RefreshControls } from "./refresh-controls" + +afterEach(cleanup) + +const cadenceTrigger = () => screen.getByRole("button", { name: /Auto-refresh/ }) +const openMenu = () => fireEvent.click(cadenceTrigger()) + +describe("RefreshControls", () => { + // The two halves are one split button: reload now on the left, reload every N + // on the right. Standalone, the cadence half read as a second time-range + // control next to the real one — attached to "Reload" it can only mean one + // thing, which is the entire reason this is one component. + it("pairs a reload action with the cadence it repeats at", () => { + const onReload = vi.fn() + render( {}} />) + + fireEvent.click(screen.getByRole("button", { name: "Reload" })) + expect(onReload).toHaveBeenCalledTimes(1) + expect(cadenceTrigger().textContent).toContain("30s") + }) + + it("says so when auto-refresh is off rather than going blank", () => { + render( {}} value={0} onChange={() => {}} />) + + expect(screen.getByRole("button", { name: "Auto-refresh off" }).textContent).toContain("Off") + }) + + // A reload already in flight must not be re-triggered by an impatient click. + it("disables reload while one is in flight", () => { + const onReload = vi.fn() + render( {}} />) + + fireEvent.click(screen.getByRole("button", { name: "Reload" })) + expect(onReload).not.toHaveBeenCalled() + }) + + // Base UI's GroupLabel throws production error #31 outside a Group, which is a + // full error-boundary crash rather than a warning — and only reachable by + // actually opening the menu. A type check would not catch this. + it("opens without throwing and names the group", () => { + render( {}} value={0} onChange={() => {}} />) + + openMenu() + expect(screen.getByText("Auto-refresh")).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "Off" })).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "15m" })).toBeDefined() + }) + + // The URL and the document both hold numbers; the radio group only speaks + // strings, so this is the seam where a stray string would leak outward. + it("reports the chosen cadence as a number", () => { + const onChange = vi.fn() + render( {}} value={0} onChange={onChange} />) + + openMenu() + fireEvent.click(screen.getByRole("menuitemradio", { name: "5s" })) + expect(onChange).toHaveBeenCalledWith(5) + }) + + it("marks the board's saved cadence so a `?refresh=` override stays legible", () => { + render( {}} value={5} onChange={() => {}} savedDefault={60} />) + + openMenu() + expect(screen.getByRole("menuitemradio", { name: /1m\s*default/ })).toBeDefined() + expect(screen.getByRole("menuitemradio", { name: "5s" }).getAttribute("aria-checked")).toBe("true") + }) +}) diff --git a/apps/web/src/components/time-range-picker/refresh-controls.tsx b/apps/web/src/components/time-range-picker/refresh-controls.tsx new file mode 100644 index 000000000..7ba88de7a --- /dev/null +++ b/apps/web/src/components/time-range-picker/refresh-controls.tsx @@ -0,0 +1,135 @@ +import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu" +import { cn } from "@maple/ui/lib/utils" + +import { ArrowRotateAnticlockwiseIcon, ChevronDownIcon } from "@/components/icons" +import { REFRESH_INTERVAL_OPTIONS } from "@/lib/dashboard-controls/search-params" + +import { usePageRefreshContext } from "./page-refresh-context" + +/** `0` is the off sentinel, so it has no duration to render. */ +export function formatRefreshInterval(seconds: DashboardRefreshIntervalSeconds): string { + if (seconds === 0) return "Off" + return seconds < 60 ? `${seconds}s` : `${seconds / 60}m` +} + +interface RefreshControlsProps { + onReload: () => void + isReloading?: boolean + value: DashboardRefreshIntervalSeconds + onChange: (value: DashboardRefreshIntervalSeconds) => void + /** + * The dashboard's stored cadence, when the caller has one. Rendered as a hint + * on the matching row so a viewer overriding via `?refresh=` can see what the + * board itself is set to. + */ + savedDefault?: DashboardRefreshIntervalSeconds +} + +/** + * Reload now, or reload every N — as one split button, the way Grafana draws it. + * + * The halves are joined rather than merely adjacent because that is what carries + * the meaning: a standalone cadence dropdown beside a standalone reload button + * reads as two unrelated controls, and next to the time-range picker its icon + * read as a second time control. Attached to "Reload", "30s" can only mean one + * thing. + */ +export function RefreshControls({ + onReload, + isReloading = false, + value, + onChange, + savedDefault, +}: RefreshControlsProps) { + const isOn = value > 0 + + return ( +
+ + + + } + > + + {formatRefreshInterval(value)} + + + + + {/* Values are strings on the wire because Base UI's RadioGroup compares + by identity and the caller round-trips them through the URL. */} + onChange(Number(next) as DashboardRefreshIntervalSeconds)} + > + {/* Names the group for screen readers as well as sighted viewers, so + the menu is self-explanatory even reached straight from the URL. + Safe inside a RadioGroup: the label only self-wraps in a Group + when nothing above it provided the context. */} + Auto-refresh + {REFRESH_INTERVAL_OPTIONS.map((seconds) => ( + + {formatRefreshInterval(seconds)} + {savedDefault === seconds && ( + default + )} + + ))} + + + +
+ ) +} + +/** + * The signed-in board's binding: reload comes from the page refresh context, so + * one tick fans out to every tile. The share page has no such context and drives + * `RefreshControls` directly. + */ +export function PageRefreshControls(props: Omit) { + const { isReloading, reload } = usePageRefreshContext() + return +} diff --git a/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx b/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx deleted file mode 100644 index 4137162f5..000000000 --- a/apps/web/src/components/time-range-picker/refresh-interval-picker.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react" -import { afterEach, describe, expect, it, vi } from "vitest" - -import { RefreshIntervalPicker } from "./refresh-interval-picker" - -afterEach(cleanup) - -const openMenu = () => fireEvent.click(screen.getByRole("button")) - -describe("RefreshIntervalPicker", () => { - // The trigger sits beside a time-range picker that also carries a clock-ish - // icon, so an icon-only control read as a second time-range control. The word - // is what makes it legible; if it goes, the ambiguity comes back. - it("names itself in the trigger whether on or off", () => { - const { rerender } = render( {}} />) - expect(screen.getByRole("button").textContent).toContain("Auto") - expect(screen.getByRole("button", { name: "Auto-refresh off" })).toBeDefined() - - rerender( {}} />) - expect(screen.getByRole("button").textContent).toContain("30s") - expect(screen.getByRole("button", { name: "Auto-refresh every 30s" })).toBeDefined() - }) - - // Base UI's GroupLabel throws production error #31 outside a Group, which is a - // full error-boundary crash rather than a warning — and only opening the menu - // exercises it. A type check would not catch this. - it("opens without throwing and names the group", () => { - render( {}} />) - - openMenu() - expect(screen.getByText("Auto-refresh")).toBeDefined() - expect(screen.getByRole("menuitemradio", { name: "Off" })).toBeDefined() - expect(screen.getByRole("menuitemradio", { name: "15m" })).toBeDefined() - }) - - // The URL and the document both hold numbers; the radio group only speaks - // strings, so this is the seam where a stray string would leak outward. - it("reports the chosen cadence as a number", () => { - const onChange = vi.fn() - render() - - openMenu() - fireEvent.click(screen.getByRole("menuitemradio", { name: "5s" })) - expect(onChange).toHaveBeenCalledWith(5) - }) - - it("marks the board's saved cadence so a `?refresh=` override stays legible", () => { - render( {}} savedDefault={60} />) - - openMenu() - expect(screen.getByRole("menuitemradio", { name: /1m\s*default/ })).toBeDefined() - expect(screen.getByRole("menuitemradio", { name: "5s" }).getAttribute("aria-checked")).toBe("true") - }) -}) diff --git a/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx b/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx deleted file mode 100644 index 5364fb824..000000000 --- a/apps/web/src/components/time-range-picker/refresh-interval-picker.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" -import { Button } from "@maple/ui/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" - -import { ArrowRotateAnticlockwiseIcon, ChevronDownIcon } from "@/components/icons" -import { REFRESH_INTERVAL_OPTIONS } from "@/lib/dashboard-controls/search-params" - -/** `0` is the off sentinel, so it has no duration to render. */ -export function formatRefreshInterval(seconds: DashboardRefreshIntervalSeconds): string { - if (seconds === 0) return "Off" - return seconds < 60 ? `${seconds}s` : `${seconds / 60}m` -} - -interface RefreshIntervalPickerProps { - value: DashboardRefreshIntervalSeconds - onChange: (value: DashboardRefreshIntervalSeconds) => void - /** - * The dashboard's stored cadence, when the caller has one. Rendered as a hint - * on the matching row so a viewer overriding via `?refresh=` can see what the - * board itself is set to. - */ - savedDefault?: DashboardRefreshIntervalSeconds -} - -export function RefreshIntervalPicker({ value, onChange, savedDefault }: RefreshIntervalPickerProps) { - const isOn = value > 0 - - return ( - - - } - > - {/* Same reload glyph as the button beside it, so the pair reads as one - control: reload now, or reload every N. A clock here read as a - second time-range picker next to the real one. */} - - {/* Always labelled. An icon-only trigger left the viewer guessing what - it did, which is the whole point of the word "Auto". */} - Auto - {isOn && {formatRefreshInterval(value)}} - - - - {/* Values are strings on the wire because Base UI's RadioGroup compares - by identity and the caller round-trips them through the URL. */} - onChange(Number(next) as DashboardRefreshIntervalSeconds)} - > - {/* Names the group for screen readers as well as sighted viewers, so - the menu is self-explanatory even reached straight from the URL. - Safe inside a RadioGroup: the label only self-wraps in a Group - when nothing above it provided the context. */} - Auto-refresh - {REFRESH_INTERVAL_OPTIONS.map((seconds) => ( - - {formatRefreshInterval(seconds)} - {savedDefault === seconds && ( - default - )} - - ))} - - - - ) -} diff --git a/apps/web/src/routes/share/$token.tsx b/apps/web/src/routes/share/$token.tsx index 72af4f8f3..a52732942 100644 --- a/apps/web/src/routes/share/$token.tsx +++ b/apps/web/src/routes/share/$token.tsx @@ -36,9 +36,8 @@ import { variableSearchRest, variableValuesFromSearch, } from "@/lib/dashboard-controls/search-params" -import { RefreshIntervalPicker } from "@/components/time-range-picker/refresh-interval-picker" +import { RefreshControls } from "@/components/time-range-picker/refresh-controls" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" -import { ArrowRotateAnticlockwiseIcon } from "@/components/icons" import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" import { formatTimeRangeDisplay, presetLabel } from "@/lib/time-utils" import { @@ -481,17 +480,12 @@ function ShareBody({
{window.label}
-
- - -
+
)} {/* Titles interpolate `$service` and friends with the values the server From 21eae258aeac2e15d137733d83bc30f6754b87a1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 17 Aug 2026 17:49:22 +0200 Subject: [PATCH 4/4] fix(dashboards): stop the grid indenting tiles by a gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-grid-layout's `containerPadding` defaults to `margin`, so the grid padded its own outside edge with a full gutter on top of whatever padding the page had already applied. Tiles ended up 12px inside everything stacked above them — section headers, the page title, and on a shared board the time-range label and refresh controls sitting directly over a left edge that did not line up with them. The gutter belongs between tiles; the surrounding layout owns the outer padding. Horizontal container padding is now zero, so a tile's left edge meets its container. Vertical keeps the margin, leaving the gap under a section header exactly as it was. Tiles gain a gutter of width on each side as a result, which is the correct amount of room and not a resize: the column count and every stored layout are untouched. --- .../canvas/dashboard-canvas.test.tsx | 29 +++++++++++++++++++ .../canvas/dashboard-canvas.tsx | 8 +++++ 2 files changed, 37 insertions(+) diff --git a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.test.tsx b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.test.tsx index 07e8dffc9..3a893d5da 100644 --- a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.test.tsx +++ b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.test.tsx @@ -55,6 +55,35 @@ describe("DashboardGrid outside a dashboard", () => { ), ).not.toThrow() }) + + // `containerPadding` defaults to `margin`, which indented the first column by + // a gutter's width — so tiles sat 12px inside everything stacked above them + // (section headers, the share page's time-range label and refresh controls). + // The gutter belongs between tiles; the surrounding layout owns the outer + // padding. Asserted on the inline position because that is the only place the + // offset exists — there is no class to look for. + it("starts the first column flush with its container, not a gutter inside it", () => { + const [tier] = GRID_TIERS + const { container } = render( + + + , + ) + + const item = container.querySelector(".react-grid-item") + expect(item).not.toBeNull() + // x only. y deliberately keeps `margin[1]`, so asserting on the whole + // transform would pass for the wrong reason (or fail for a good one). + const [x, y] = (item?.style.transform ?? "").match(/-?\d+(\.\d+)?px/g) ?? [] + expect(x).toBe("0px") + expect(y).toBe(`${tier.margin[1]}px`) + }) }) describe("SharedWidgetRenderer", () => { diff --git a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx index a3e05402f..63a591fb8 100644 --- a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx +++ b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx @@ -112,6 +112,14 @@ export function DashboardGrid({ cols: tier.cols, rowHeight: GRID_ROW_HEIGHT, margin: tier.margin, + // `containerPadding` defaults to `margin`, which indents the first and + // last column by a gutter's width — so tiles sat inset from everything + // stacked above them (section headers, the share page's time-range + // label and refresh controls, the page title). The gutter belongs + // *between* tiles, not around them; the surrounding layout owns the + // outer padding. Vertical keeps the margin, so the gap under a section + // header is unchanged. + containerPadding: [0, tier.margin[1]], }} dragConfig={{ enabled: editable,