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, 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..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,7 +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 { 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" @@ -33,12 +33,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 +50,8 @@ export function DashboardToolbar({ onToggleEdit, onAddWidget, onOpenHistory, + refreshIntervalSeconds, + onRefreshIntervalChange, }: DashboardToolbarProps) { const { mode, readOnly, autoLayoutWidgets, addSection } = useDashboardActions() const { @@ -94,7 +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/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-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/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..a52732942 100644 --- a/apps/web/src/routes/share/$token.tsx +++ b/apps/web/src/routes/share/$token.tsx @@ -30,7 +30,15 @@ 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 { RefreshControls } from "@/components/time-range-picker/refresh-controls" +import { useIntervalRefresh } from "@/hooks/use-interval-refresh" +import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http" import { formatTimeRangeDisplay, presetLabel } from "@/lib/time-utils" import { shareTimeRange, @@ -55,6 +63,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 +101,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 +110,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 +180,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 +371,7 @@ function ShareBody({ from, to, search, + refreshParam, signedIn, embed, }: { @@ -363,14 +380,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 +419,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 +429,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 }