diff --git a/dashboard/src/components/dashboards/AddWidgetDrawer.tsx b/dashboard/src/components/dashboards/AddWidgetDrawer.tsx
new file mode 100644
index 00000000..b46747e7
--- /dev/null
+++ b/dashboard/src/components/dashboards/AddWidgetDrawer.tsx
@@ -0,0 +1,88 @@
+import { CHART_TYPES } from "@/components/signals/chartTypes";
+import type { ChartType } from "@/components/signals/ChartTypeToggle";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { cn } from "@/lib/utils";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Called with the picked chart type; the dashboard creates a fresh
+ * `signal` widget seeded with that chart type. */
+ onPick: (chartType: ChartType) => void;
+}
+
+/** Right-edge drawer that lists every widget type the dashboard knows
+ * how to render. PR #1 ships only the signal-driven chart family;
+ * specialty widgets (gauge, big-number, dedicated map) join this list
+ * in PR #2 and get the same picker treatment. */
+export function AddWidgetDrawer({ open, onOpenChange, onPick }: Props) {
+ return (
+
+
+
+ Add widget
+
+ Pick a chart type to drop on the dashboard. Every widget is
+ driven by an MQL query — you can edit it after.
+
+
+
+
+
+ Charts
+
+
+ {CHART_TYPES.map((t) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+// One-line summary per chart type. Kept here (not on the registry) so
+// the registry stays a thin data structure and the copy can be tuned
+// without rewiring the rest of the codebase.
+function describeChartType(t: ChartType): string {
+ switch (t) {
+ case "bar":
+ return "Counts or sums grouped into time buckets.";
+ case "line":
+ return "Continuous values over time.";
+ case "area":
+ return "Line chart filled under the curve — emphasizes totals.";
+ case "scatter":
+ return "Two signals as x/y pairs.";
+ case "path":
+ return "Two signals with connecting lines — good for GPS / trajectory.";
+ case "scatter3d":
+ return "Three signals as x/y/z pairs (orbit-controllable).";
+ case "catbar":
+ return "Categorical aggregate — one bar per signal name.";
+ case "pie":
+ return "Share-of-total across categories.";
+ }
+}
diff --git a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx
new file mode 100644
index 00000000..b8dd011f
--- /dev/null
+++ b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx
@@ -0,0 +1,208 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { SignalWidget } from "@/components/signals/SignalWidget";
+import { cn } from "@/lib/utils";
+import { parseQuery } from "@/lib/query";
+import type {
+ DashboardWidget,
+ SignalWidgetConfig,
+} from "@/models/dashboard";
+import type { Lap } from "@/models/session";
+import { GripVertical, Pencil, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import type { ChartType } from "@/components/signals/ChartTypeToggle";
+
+interface Props {
+ widget: DashboardWidget;
+ vehicleId: string;
+ vehicleType: string;
+ signalNames: string[];
+ startIso: string;
+ endIso: string;
+ rangeSeconds: number;
+ isRolling: boolean;
+ groupId: string;
+ laps?: Lap[] | null;
+ onRemove: () => void;
+ onConfigChange: (next: SignalWidgetConfig) => void;
+}
+
+/** Widget shell — drag handle, title, edit, remove. Body renders the
+ * chart-only SignalWidget; clicking edit opens a dialog with the full
+ * builder. Card and dialog are mutually-exclusive (the card's
+ * SignalWidget unmounts while editing) so their internal query/chart
+ * state can never drift apart. */
+export function DashboardWidgetCard({
+ widget,
+ vehicleId,
+ vehicleType,
+ signalNames,
+ startIso,
+ endIso,
+ rangeSeconds,
+ isRolling,
+ groupId,
+ laps,
+ onRemove,
+ onConfigChange,
+}: Props) {
+ const [editing, setEditing] = useState(false);
+
+ if (widget.type !== "signal") {
+ return (
+
+
+ Unsupported widget type — update the dashboard to render it.
+
+
+ );
+ }
+
+ const config = widget.config as SignalWidgetConfig;
+ const handleTitleChange = (title: string) =>
+ onConfigChange({ ...config, title });
+ const handleQueriesChange = (queries: string[]) =>
+ onConfigChange({ ...config, queries });
+ const handleChartTypeChange = (chart_type: ChartType) =>
+ onConfigChange({ ...config, chart_type });
+
+ // Pull every `where(name = "...")` literal out of each query so the
+ // streaming subscription only sees signals the chart actually plots.
+ // Queries with no name filter get skipped here (subscribing to "*"
+ // would flood the wire).
+ const streamSignalPatterns = useMemo(() => {
+ const set = new Set();
+ for (const mql of config.queries) {
+ const res = parseQuery(mql);
+ if (!res.ok) continue;
+ for (const p of res.query.filters) {
+ if (p.column !== "name" || p.op !== "=" || !p.value) continue;
+ set.add(p.value);
+ }
+ }
+ return Array.from(set);
+ }, [config.queries]);
+
+ // Shared props every SignalWidget instance receives. Used twice —
+ // once for the card-chart, once for the edit-dialog editor.
+ const sharedSignalWidgetProps = {
+ vehicleId,
+ vehicleType,
+ signalNames,
+ startIso,
+ endIso,
+ rangeSeconds,
+ groupId,
+ hidden: false,
+ onToggleHide: () => undefined,
+ onDelete: onRemove,
+ onBrushSelect: () => undefined,
+ laps: laps ?? null,
+ seedQueries: config.queries,
+ onQueriesChange: handleQueriesChange,
+ seedChartType: (config.chart_type as ChartType | undefined) ?? "bar",
+ onChartTypeChange: handleChartTypeChange,
+ refreshIntervalSec: isRolling ? 5 : undefined,
+ streamSignalPatterns: isRolling ? streamSignalPatterns : undefined,
+ };
+
+ return (
+ <>
+ setEditing(true)}
+ onRemove={onRemove}
+ >
+
+ {/* Keying on the queries+chart_type causes a remount when the
+ dialog persists a change, so the card picks up the new
+ seed instead of being stuck on stale internal state. */}
+ {!editing && (
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx
index 9b13ec47..7a7d68f9 100644
--- a/dashboard/src/components/signals/SignalWidget.tsx
+++ b/dashboard/src/components/signals/SignalWidget.tsx
@@ -68,6 +68,7 @@ import {
} from "@/lib/query";
import { cn } from "@/lib/utils";
import { useDebouncedValue } from "@/lib/useDebouncedValue";
+import { useLiveTrigger } from "@/lib/useLiveTrigger";
import {
ChevronDown,
ChevronRight,
@@ -83,7 +84,7 @@ import {
Trash2,
X,
} from "lucide-react";
-import { useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
type Interval = Rollup;
@@ -207,6 +208,33 @@ export interface SignalWidgetProps {
/** Laps of the currently-selected session, enabling the `lap` highlight
* pseudo-variable + the "alternate by lap" shortcut. */
laps?: Lap[] | null;
+ /** Seed the initial query list — used when the widget is embedded in a
+ * saved dashboard so its persisted queries replace the default
+ * `count(signal.name)`. The local chip state still owns subsequent
+ * edits; the parent receives them via onQueriesChange. */
+ seedQueries?: string[];
+ /** Notify the parent every time the chip rows commit a change, so a
+ * dashboard widget can persist its config without polling. */
+ onQueriesChange?: (queries: string[]) => void;
+ /** Seed the initial chart type. Same write-once-then-controlled-locally
+ * contract as seedQueries — the toolbar inside still owns subsequent
+ * changes, the parent observes via onChartTypeChange. */
+ seedChartType?: ChartType;
+ onChartTypeChange?: (next: ChartType) => void;
+ /** Rolling-window refresh interval in seconds. When set, the widget
+ * re-runs its query every N seconds so the chart's right edge keeps
+ * tracking `now`. */
+ refreshIntervalSec?: number;
+ /** Signal-name patterns to subscribe to via /live/sse. On each
+ * arrival the chart triggers a fresh /query/run, giving sub-second
+ * perceived latency without re-implementing aggregation client-side.
+ * Empty/undefined → no live subscription. */
+ streamSignalPatterns?: string[];
+ /** Suppress the query-builder header + chart-type picker, leaving
+ * only the chart canvas. Dashboards use this so the rendered widget
+ * is just the visualization; query edits happen in a dialog opened
+ * from the parent. */
+ chartOnly?: boolean;
}
export function SignalWidget({
@@ -225,14 +253,28 @@ export function SignalWidget({
interactionMode,
onInteractionModeChange,
laps,
+ seedQueries,
+ onQueriesChange,
+ seedChartType,
+ onChartTypeChange,
+ refreshIntervalSec,
+ streamSignalPatterns,
+ chartOnly,
}: SignalWidgetProps) {
// Ordered list of MQL trace statements, classified at render via
// `looksLikeFetchQuery`: fetch statements hit /query/run; expression
// statements evaluate in-browser over the fetched base series. The chip rows
// and the raw MQL editor are two views of this one list.
- const [queries, setQueries] = useState([
- { id: newQueryId(), mql: "count(signal.name)" },
- ]);
+ //
+ // `seedQueries` lets a parent prefill the list at mount (dashboard
+ // widget restoring saved MQL); subsequent edits stay local and the
+ // parent observes via onQueriesChange. The seed is read-once on first
+ // render — later parent changes won't yank the user's caret mid-edit.
+ const [queries, setQueries] = useState(() =>
+ seedQueries && seedQueries.length > 0
+ ? seedQueries.map((mql) => ({ id: newQueryId(), mql }))
+ : [{ id: newQueryId(), mql: "count(signal.name)" }],
+ );
// While a row's field is focused, freeze its kind: `looksLikeFetchQuery`
// flips at the `(`, and re-classifying mid-type would swap the input element
// and yank the caret. Re-classified on blur.
@@ -240,7 +282,22 @@ export function SignalWidget({
id: string;
kind: "fetch" | "expr";
} | null>(null);
- const [chartType, setChartType] = useState("bar");
+ const [chartType, setChartType] = useState(seedChartType ?? "bar");
+
+ // Bubble query/chart changes up to the parent (dashboard widget) so it
+ // can persist them. Guarded against firing on the initial render.
+ const initialQueriesRef = useRef(queries);
+ useEffect(() => {
+ if (!onQueriesChange) return;
+ if (queries === initialQueriesRef.current) return;
+ onQueriesChange(queries.map((q) => q.mql));
+ }, [queries, onQueriesChange]);
+ const initialChartTypeRef = useRef(chartType);
+ useEffect(() => {
+ if (!onChartTypeChange) return;
+ if (chartType === initialChartTypeRef.current) return;
+ onChartTypeChange(chartType);
+ }, [chartType, onChartTypeChange]);
// Per-trace y-scaling, keyed by series label. Sparse; absent = default.
const [axisSettings, setAxisSettings] = useState<
Record
@@ -316,14 +373,44 @@ export function SignalWidget({
() => fetchPlan.filter((p) => p.runnable),
[fetchPlan],
);
+ // Rolling-window refresh has two drivers, OR'd into a single tick:
+ // 1. setInterval at refreshIntervalSec — guarantees the chart still
+ // moves forward during signal lulls.
+ // 2. useLiveTrigger SSE — bumps the moment fresh samples land,
+ // giving sub-second perceived latency under normal traffic.
+ // Both bumps fold into the same `refreshTick` so the historical fetch
+ // effect re-fires through its existing fetchKey path — the backend
+ // stays the source of truth for bucket math.
+ const [refreshTick, setRefreshTick] = useState(0);
+ useEffect(() => {
+ if (!refreshIntervalSec || refreshIntervalSec <= 0) return;
+ const t = setInterval(
+ () => setRefreshTick((n) => n + 1),
+ refreshIntervalSec * 1000,
+ );
+ return () => clearInterval(t);
+ }, [refreshIntervalSec]);
+ const liveTrigger = useLiveTrigger({
+ vehicleId,
+ signalPatterns: streamSignalPatterns ?? [],
+ enabled: Boolean(streamSignalPatterns && streamSignalPatterns.length > 0),
+ });
+ useEffect(() => {
+ if (liveTrigger.tick === 0) return;
+ setRefreshTick((n) => n + 1);
+ }, [liveTrigger.tick]);
+
// Stable key over the wire form, so the fetch effect fires only on real change.
const fetchKey = useMemo(
() =>
JSON.stringify({
ids: runnableFetches.map((p) => `${p.id}:${p.mql}`),
interval,
+ // Rolling-window tick: only contributes to the key when the
+ // parent opted in to refresh, otherwise stays a constant 0.
+ tick: refreshIntervalSec || streamSignalPatterns?.length ? refreshTick : 0,
}),
- [runnableFetches, interval],
+ [runnableFetches, interval, refreshIntervalSec, streamSignalPatterns, refreshTick],
);
// Debounce so editing the MQL line doesn't fire /query/run per keystroke
// (each response forces a synchronous re-render that drops typing). Timeframe/
@@ -679,7 +766,8 @@ export function SignalWidget({
}, [path, classified, chartType]);
return (
-
+
+ {!chartOnly && (
@@ -885,14 +973,18 @@ export function SignalWidget({
)}
+ )}
{!hidden && (
-
+
{/* Chart-type lives here (not in the widget header) — it picks
what the chart canvas renders, so it belongs with the
- chart-content controls. */}
+ chart-content controls. Suppressed in chartOnly mode
+ (dashboards manage chart type via the edit dialog). */}
+ {!chartOnly && (
+ )}
{path === "timeseries" && onInteractionModeChange && (
// Left-drag mode sits just above the chart so it's a short hop to
// the gesture; "select" brushes a timeframe, "pan" slides the zoom.
diff --git a/dashboard/src/components/ui/sheet.tsx b/dashboard/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..450fdecb
--- /dev/null
+++ b/dashboard/src/components/ui/sheet.tsx
@@ -0,0 +1,105 @@
+// Side-anchored modal — a Dialog with translate-from-edge animation
+// instead of the centered zoom. Used for the dashboard "Add widget"
+// drawer; reusable for any right-edge panel that benefits from staying
+// connected to a visible page background.
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Sheet = DialogPrimitive.Root;
+const SheetTrigger = DialogPrimitive.Trigger;
+const SheetClose = DialogPrimitive.Close;
+const SheetPortal = DialogPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = "SheetOverlay";
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef {
+ /** Which edge to anchor against. Defaults to "right". */
+ side?: "right" | "left";
+}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ className, children, side = "right", ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = "SheetContent";
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = "SheetTitle";
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = "SheetDescription";
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/dashboard/src/lib/dashboards.ts b/dashboard/src/lib/dashboards.ts
new file mode 100644
index 00000000..1ea2cd57
--- /dev/null
+++ b/dashboard/src/lib/dashboards.ts
@@ -0,0 +1,87 @@
+// HTTP client for the dashboards CRUD surface. The gateway wraps each
+// response once under `data`, so Go endpoints (bare body) read as
+// `response.data.data` — matches the convention sessions/api.ts uses.
+
+import { BACKEND_URL } from "@/consts/config";
+import { http } from "@/lib/http";
+import type {
+ Dashboard,
+ DashboardWidget,
+ WidgetConfig,
+ WidgetType,
+} from "@/models/dashboard";
+
+export async function fetchDashboards(): Promise {
+ const r = await http.get(`${BACKEND_URL}/dashboards`);
+ return (r.data?.data as Dashboard[]) ?? [];
+}
+
+export async function fetchDashboard(id: string): Promise {
+ const r = await http.get(`${BACKEND_URL}/dashboards/${id}`);
+ return r.data?.data as Dashboard;
+}
+
+export async function createDashboard(input: {
+ name: string;
+ description?: string;
+}): Promise {
+ const r = await http.post(`${BACKEND_URL}/dashboards`, {
+ name: input.name,
+ description: input.description ?? "",
+ });
+ return r.data?.data as Dashboard;
+}
+
+export async function updateDashboard(
+ id: string,
+ patch: { name?: string; description?: string },
+): Promise {
+ // The backend's UpdateDashboard preserves CreatedBy/CreatedAt; we only
+ // need to send the mutable fields.
+ const r = await http.put(`${BACKEND_URL}/dashboards/${id}`, patch);
+ return r.data?.data as Dashboard;
+}
+
+export async function deleteDashboard(id: string): Promise {
+ await http.delete(`${BACKEND_URL}/dashboards/${id}`);
+}
+
+export async function createWidget(
+ dashboardID: string,
+ input: {
+ type: WidgetType;
+ config: WidgetConfig;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+): Promise {
+ const r = await http.post(
+ `${BACKEND_URL}/dashboards/${dashboardID}/widgets`,
+ input,
+ );
+ return r.data?.data as DashboardWidget;
+}
+
+// Drag/resize fires UpdateWidget on every release. The grid is the
+// source of truth for x/y/w/h; the page debounces config-only changes
+// so each chip edit isn't its own PUT.
+export async function updateWidget(
+ dashboardID: string,
+ widgetID: string,
+ widget: Partial,
+): Promise {
+ const r = await http.put(
+ `${BACKEND_URL}/dashboards/${dashboardID}/widgets/${widgetID}`,
+ widget,
+ );
+ return r.data?.data as DashboardWidget;
+}
+
+export async function deleteWidget(
+ dashboardID: string,
+ widgetID: string,
+): Promise {
+ await http.delete(`${BACKEND_URL}/dashboards/${dashboardID}/widgets/${widgetID}`);
+}
diff --git a/dashboard/src/lib/useLiveTrigger.ts b/dashboard/src/lib/useLiveTrigger.ts
new file mode 100644
index 00000000..77a226e9
--- /dev/null
+++ b/dashboard/src/lib/useLiveTrigger.ts
@@ -0,0 +1,108 @@
+// Opens an SSE subscription to the live service and surfaces a monotonic
+// `tick` that bumps every time fresh samples arrive (throttled to one bump
+// per `throttleMs`). Consumers include `tick` in their query/fetch key so
+// the data layer re-pulls from /query/run as soon as new signals land.
+//
+// The hook does NOT do any client-side aggregation — the backend stays the
+// source of truth for bucket math (which is gnarly to mirror correctly
+// across all the MQL aggregators). SSE is purely a wake-up signal so the
+// dashboard's rolling-window chart updates in near-real-time instead of
+// the slower setInterval polling cadence.
+//
+// const { tick } = useLiveTrigger({
+// vehicleId,
+// signalPatterns: ["ecu_acc_pedal", "ecu_*"],
+// enabled: isRolling,
+// throttleMs: 500,
+// });
+//
+// Empty signalPatterns or enabled=false means "do nothing"; the hook
+// returns tick=0 and never opens a connection.
+
+import { useEffect, useRef, useState } from "react";
+import { BACKEND_URL } from "@/consts/config";
+
+interface UseLiveTriggerArgs {
+ vehicleId: string;
+ /** Signal names (or globs — same syntax `/live/sse?signals=` accepts). */
+ signalPatterns: string[];
+ /** When false the hook is a no-op (no connection, no tick bumps). */
+ enabled: boolean;
+ /** Minimum gap between consecutive tick bumps. A high-rate signal
+ * storm would otherwise flood downstream effects with refetches. */
+ throttleMs?: number;
+}
+
+export interface UseLiveTriggerResult {
+ /** Monotonic counter. Increments on each throttled batch of samples. */
+ tick: number;
+ /** "open" once the SSE handshake completes; "error" on dropped
+ * connection. Surfaces in the parent's status indicator if it
+ * wants to show "live" / "live offline" badges. */
+ status: "idle" | "open" | "error";
+}
+
+export function useLiveTrigger({
+ vehicleId,
+ signalPatterns,
+ enabled,
+ throttleMs = 500,
+}: UseLiveTriggerArgs): UseLiveTriggerResult {
+ const [tick, setTick] = useState(0);
+ const [status, setStatus] = useState<"idle" | "open" | "error">("idle");
+
+ // Stable key for the patterns so identical sets across renders don't
+ // tear down + reopen the SSE. Sort to absorb caller ordering drift.
+ const patternsKey = signalPatterns
+ .slice()
+ .sort()
+ .join(",");
+
+ // Track the last tick-bump time so a high-rate signal storm doesn't
+ // flood the downstream refetch effect. Kept in a ref so the throttle
+ // doesn't reset across renders.
+ const lastBumpRef = useRef(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ setStatus("idle");
+ return;
+ }
+ if (!vehicleId || !patternsKey) {
+ setStatus("idle");
+ return;
+ }
+
+ const url = new URL(`${BACKEND_URL}/live/sse`, window.location.origin);
+ url.searchParams.set("vehicle_id", vehicleId);
+ url.searchParams.set("signals", patternsKey);
+ // `backfill=0` because we just pulled the historical query — we
+ // don't want SSE to replay the same window we already rendered.
+ url.searchParams.set("backfill", "0");
+
+ const es = new EventSource(url.toString());
+
+ es.addEventListener("open", () => setStatus("open"));
+ es.addEventListener("error", () => setStatus("error"));
+
+ const onSignal = () => {
+ const now = Date.now();
+ if (now - lastBumpRef.current < throttleMs) return;
+ lastBumpRef.current = now;
+ setTick((n) => n + 1);
+ };
+ // The live service emits two named event types: `backfill` (initial,
+ // possibly empty) and `signal` (each new sample). The backfill event
+ // is intentionally ignored — historical data is already on the
+ // chart from /query/run.
+ es.addEventListener("signal", onSignal);
+
+ return () => {
+ es.removeEventListener("signal", onSignal);
+ es.close();
+ setStatus("idle");
+ };
+ }, [enabled, vehicleId, patternsKey, throttleMs]);
+
+ return { tick, status };
+}
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
index 6f2eb11f..32a2addd 100644
--- a/dashboard/src/main.tsx
+++ b/dashboard/src/main.tsx
@@ -21,6 +21,8 @@ import DebugPage from "@/pages/debug/DebugPage.tsx";
import SessionsPage from "@/pages/sessions/SessionsPage.tsx";
import SessionDetailPage from "@/pages/sessions/SessionDetailPage.tsx";
import SessionEditorPage from "@/pages/sessions/SessionEditorPage.tsx";
+import DashboardsPage from "@/pages/dashboards/DashboardsPage.tsx";
+import DashboardDetailsPage from "@/pages/dashboards/DashboardDetailsPage.tsx";
import { useRoseMode } from "@/lib/store";
import { useEffect } from "react";
@@ -81,6 +83,14 @@ const router = createBrowserRouter([
path: "/sessions/:id/edit",
element: ,
},
+ {
+ path: "/dashboards",
+ element: ,
+ },
+ {
+ path: "/dashboards/:id",
+ element: ,
+ },
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
diff --git a/dashboard/src/models/dashboard.ts b/dashboard/src/models/dashboard.ts
new file mode 100644
index 00000000..b538f811
--- /dev/null
+++ b/dashboard/src/models/dashboard.ts
@@ -0,0 +1,66 @@
+// Mirrors mapache-go's Dashboard + DashboardWidget. Field names are
+// snake_case on the wire and camelCase nowhere — the dashboard fetches
+// the raw JSON and consumes it without aliasing, so keep these shapes
+// byte-identical to the Go structs.
+
+export interface DashboardWidget {
+ id: string;
+ dashboard_id: string;
+ // Widget renderer key. `signal` is the only one shipped in PR #1;
+ // future types (`gauge`, `map`, `bignumber`, `table`) plug into the
+ // same registry.
+ type: WidgetType;
+ // Type-specific settings (e.g. for `signal`: queries[], chart_type,
+ // axis overrides). Stored as jsonb on the backend.
+ config: WidgetConfig;
+ // react-grid-layout coordinates. The dashboard grid is 12 cols wide;
+ // h/w are in cell units, x/y are zero-based.
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ updated_at: string;
+ created_at: string;
+}
+
+export type WidgetType = "signal";
+
+// Each widget renderer carries its own config shape. The signal widget
+// stores the MQL statements + chart-type + a couple of display knobs;
+// future widget types will widen this union.
+export type WidgetConfig = SignalWidgetConfig;
+
+export interface SignalWidgetConfig {
+ // Title shown in the widget's header. Empty falls back to "Untitled".
+ title?: string;
+ // MQL statements (one per line in the chip editor). At least one.
+ queries: string[];
+ // Chart-type key from the existing chartTypes registry (bar, line,
+ // area, scatter, …). Defaults to "bar" if missing.
+ chart_type?: string;
+}
+
+export interface Dashboard {
+ id: string;
+ name: string;
+ description: string;
+ created_by: string;
+ widgets: DashboardWidget[];
+ updated_at: string;
+ created_at: string;
+}
+
+export const initDashboard: Dashboard = {
+ id: "",
+ name: "",
+ description: "",
+ created_by: "",
+ widgets: [],
+ updated_at: "",
+ created_at: "",
+};
+
+// Default coordinates for a freshly-added widget. The grid is 12 cols
+// wide; w=6 / h=8 gives a half-width chart that's tall enough to read.
+// Placement (x, y) is set by the page based on whatever's already there.
+export const DEFAULT_WIDGET_SIZE = { w: 6, h: 8 } as const;
diff --git a/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx
new file mode 100644
index 00000000..2f654d7b
--- /dev/null
+++ b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx
@@ -0,0 +1,366 @@
+import Layout from "@/components/Layout";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { OutlineButton } from "@/components/ui/outline-button";
+import { getAxiosErrorMessage } from "@/lib/axios-error-handler";
+import { BACKEND_URL } from "@/consts/config";
+import {
+ createWidget,
+ deleteWidget,
+ fetchDashboard,
+ updateDashboard,
+ updateWidget,
+} from "@/lib/dashboards";
+import { notify } from "@/lib/notify";
+import { cn } from "@/lib/utils";
+import { useVehicle } from "@/lib/store";
+import {
+ DEFAULT_WIDGET_SIZE,
+ type Dashboard,
+ type DashboardWidget,
+ type SignalWidgetConfig,
+} from "@/models/dashboard";
+import axios from "axios";
+import { ArrowLeft, Loader2, Plus } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import GridLayout, { type Layout as RGLLayout } from "react-grid-layout";
+import { useNavigate, useParams } from "react-router-dom";
+import "react-grid-layout/css/styles.css";
+import "react-resizable/css/styles.css";
+import { DashboardWidgetCard } from "@/components/dashboards/DashboardWidgetCard";
+import { AddWidgetDrawer } from "@/components/dashboards/AddWidgetDrawer";
+import {
+ defaultTimeframe,
+ type Timeframe,
+ TimeframePicker,
+} from "@/components/signals/TimeframePicker";
+import type { ChartType } from "@/components/signals/ChartTypeToggle";
+
+const GRID_COLS = 12;
+const ROW_HEIGHT = 30;
+const MARGIN: [number, number] = [12, 12];
+
+const SYNC_GROUP_ID = "dashboard-widgets";
+
+function DashboardDetailsPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const vehicle = useVehicle();
+ const [dashboard, setDashboard] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [gridWidth, setGridWidth] = useState(1200);
+ // Page-level timeframe — every widget plots the same window. Will
+ // eventually allow per-widget override; for now the dashboard reads
+ // as one cohesive view.
+ const [timeframe, setTimeframe] = useState(defaultTimeframe);
+ // Cached signal-name list for the active vehicle, used by the chip
+ // builder's autocomplete inside each widget.
+ const [signalNames, setSignalNames] = useState([]);
+ const [addOpen, setAddOpen] = useState(false);
+
+ useEffect(() => {
+ if (!vehicle?.id) return;
+ let cancelled = false;
+ axios
+ .get(`${BACKEND_URL}/query/signals`, {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`,
+ },
+ params: { vehicle_id: vehicle.id },
+ })
+ .then((res) => {
+ if (cancelled) return;
+ const rows = res.data?.data?.data ?? res.data?.data ?? [];
+ setSignalNames(rows.map((r: { name: string }) => r.name));
+ })
+ .catch(() => {
+ // Autocomplete is non-blocking — failure just means the
+ // dropdown shows nothing. The chart still runs.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [vehicle?.id]);
+
+ // Resolve the timeframe to the iso strings + range every widget needs.
+ // "Rolling" mode = the window's right edge tracks `now`; that's true
+ // for every Past-N preset and for any custom range whose end is within
+ // a few seconds of now. Live-blending widgets watch this flag.
+ const { startIso, endIso, rangeSeconds, isRolling } = useMemo(() => {
+ const start = timeframe.start.toISOString();
+ const end = timeframe.end.toISOString();
+ const range = Math.max(
+ 1,
+ Math.round((timeframe.end.getTime() - timeframe.start.getTime()) / 1000),
+ );
+ const rolling =
+ timeframe.label.startsWith("Past ") ||
+ Math.abs(timeframe.end.getTime() - Date.now()) < 5_000;
+ return { startIso: start, endIso: end, rangeSeconds: range, isRolling: rolling };
+ }, [timeframe]);
+
+ // Track the rendered area's width so the grid sizes columns to the
+ // container, not the viewport — sidebars and padding both eat real estate.
+ useEffect(() => {
+ const measure = () => {
+ const el = document.getElementById("dashboard-grid-host");
+ if (el) setGridWidth(el.clientWidth);
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ const el = document.getElementById("dashboard-grid-host");
+ if (el) ro.observe(el);
+ window.addEventListener("resize", measure);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", measure);
+ };
+ }, [dashboard]);
+
+ const reload = useCallback(async () => {
+ if (!id) return;
+ setLoading(true);
+ try {
+ setDashboard(await fetchDashboard(id));
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ reload();
+ }, [reload]);
+
+ const handleRename = async (name: string) => {
+ if (!dashboard || name === dashboard.name) return;
+ try {
+ const next = await updateDashboard(dashboard.id, { name });
+ setDashboard((d) => (d ? { ...d, ...next } : d));
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ }
+ };
+
+ // react-grid-layout calls onLayoutChange on every drag/resize commit.
+ // The widget IDs are stable so the layout array maps cleanly back to
+ // our widget rows; fire one PUT per widget whose coords actually moved.
+ const handleLayoutChange = async (layout: RGLLayout[]) => {
+ if (!dashboard) return;
+ const byId = new Map(layout.map((l) => [l.i, l] as const));
+ const moved: DashboardWidget[] = [];
+ for (const w of dashboard.widgets) {
+ const l = byId.get(w.id);
+ if (!l) continue;
+ if (l.x === w.x && l.y === w.y && l.w === w.w && l.h === w.h) continue;
+ moved.push({ ...w, x: l.x, y: l.y, w: l.w, h: l.h });
+ }
+ if (moved.length === 0) return;
+ // Optimistic update — assume the PUT succeeds. A failure logs but
+ // doesn't roll back the grid; the user can resize again to retry.
+ setDashboard((d) =>
+ d
+ ? {
+ ...d,
+ widgets: d.widgets.map(
+ (w) => moved.find((m) => m.id === w.id) ?? w,
+ ),
+ }
+ : d,
+ );
+ try {
+ await Promise.all(
+ moved.map((w) => updateWidget(dashboard.id, w.id, w)),
+ );
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ }
+ };
+
+ const handleAddSignalWidget = async (chartType: ChartType = "bar") => {
+ if (!dashboard) return;
+ const config: SignalWidgetConfig = {
+ title: "New widget",
+ queries: ["count(signal.name)"],
+ chart_type: chartType,
+ };
+ // Place new widgets at the bottom of the current layout so they
+ // never overlap. y = max(y + h) across existing widgets.
+ const yBottom = dashboard.widgets.reduce(
+ (m, w) => Math.max(m, w.y + w.h),
+ 0,
+ );
+ try {
+ const w = await createWidget(dashboard.id, {
+ type: "signal",
+ config,
+ x: 0,
+ y: yBottom,
+ w: DEFAULT_WIDGET_SIZE.w,
+ h: DEFAULT_WIDGET_SIZE.h,
+ });
+ setDashboard((d) => (d ? { ...d, widgets: [...d.widgets, w] } : d));
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ }
+ };
+
+ const handleRemoveWidget = async (widgetID: string) => {
+ if (!dashboard) return;
+ try {
+ await deleteWidget(dashboard.id, widgetID);
+ setDashboard((d) =>
+ d ? { ...d, widgets: d.widgets.filter((w) => w.id !== widgetID) } : d,
+ );
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ }
+ };
+
+ const handleUpdateWidgetConfig = async (
+ widgetID: string,
+ config: SignalWidgetConfig,
+ ) => {
+ if (!dashboard) return;
+ const existing = dashboard.widgets.find((w) => w.id === widgetID);
+ if (!existing) return;
+ const next = { ...existing, config };
+ setDashboard((d) =>
+ d
+ ? { ...d, widgets: d.widgets.map((w) => (w.id === widgetID ? next : w)) }
+ : d,
+ );
+ try {
+ await updateWidget(dashboard.id, widgetID, next);
+ } catch (e) {
+ notify.error(getAxiosErrorMessage(e));
+ }
+ };
+
+ const layout = useMemo(
+ () =>
+ dashboard?.widgets.map((w) => ({
+ i: w.id,
+ x: w.x,
+ y: w.y,
+ w: w.w,
+ h: w.h,
+ minW: 2,
+ minH: 3,
+ })) ?? [],
+ [dashboard],
+ );
+
+ if (loading) {
+ return (
+
+