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
}