From 0bdec08f7f5659d460ad6afc9866ae7f49a5fa42 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 08:04:32 -0400 Subject: [PATCH 1/3] feat(dashboards): MVP backend + grid frontend (PR 1 of 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1 of the dashboards feature — gets the backend + grid layout in place so the rest of the work is purely widget-side. mapache-go: - New Dashboard + DashboardWidget models. Widget.Config is jsonb so each renderer carries its own type-specific shape without schema churn per widget type. X/Y/W/H are react-grid-layout cell coords. vehicle service: - CRUD endpoints under /dashboards and /dashboards/:id/widgets. - Widget update is granular (PUT per widget) because the grid's drag/resize fires UpdateWidget on every release; bundling the whole dashboard would make every move pay for the full widget payload. - AutoMigrate adds the two new tables. - go.mod gets a `replace` directive against the in-repo mapache-go so adding model types doesn't require a tag-and-release detour. Drop the replace + bump the version once mapache-go is released. kerbecs: - Two new routes: /api/dashboards (list) and /api/dashboards/* (rest). dashboard frontend: - DashboardsPage lists existing dashboards in a card grid with a create dialog. - DashboardDetailsPage embeds react-grid-layout with optimistic position/size persistence and an inline-renamed title. - DashboardWidgetCard is the widget shell — drag handle, title input, remove. The chart pane is a placeholder for PR #1; PR #2 plugs in actual SignalWidget rendering once it's refactored to accept seeded queries. - New sidebar entry "Dashboards" at /dashboards. Out of scope for PR #1 (parked for #2/#3): - Actual chart rendering inside the widget card. - Widget settings panel for editing queries (today's flow seeds them on create; PR #2 adds the editor). - Specialty widget types (gauge, big number, dedicated map). - Sharing / permissions / duplication. --- dashboard/package.json | 2 + dashboard/src/components/Sidebar.tsx | 8 + .../dashboards/DashboardWidgetCard.tsx | 104 +++++++ dashboard/src/lib/dashboards.ts | 87 ++++++ dashboard/src/main.tsx | 10 + dashboard/src/models/dashboard.ts | 66 +++++ .../pages/dashboards/DashboardDetailsPage.tsx | 280 ++++++++++++++++++ .../src/pages/dashboards/DashboardsPage.tsx | 201 +++++++++++++ kerbecs.yaml | 16 + mapache-go/dashboard.go | 51 ++++ vehicle/api/api.go | 12 + vehicle/api/dashboard.go | 116 ++++++++ vehicle/database/db.go | 1 + vehicle/go.mod | 6 + vehicle/service/dashboard.go | 75 +++++ 15 files changed, 1035 insertions(+) create mode 100644 dashboard/src/components/dashboards/DashboardWidgetCard.tsx create mode 100644 dashboard/src/lib/dashboards.ts create mode 100644 dashboard/src/models/dashboard.ts create mode 100644 dashboard/src/pages/dashboards/DashboardDetailsPage.tsx create mode 100644 dashboard/src/pages/dashboards/DashboardsPage.tsx create mode 100644 mapache-go/dashboard.go create mode 100644 vehicle/api/dashboard.go create mode 100644 vehicle/service/dashboard.go diff --git a/dashboard/package.json b/dashboard/package.json index 1a4ec5a7..2f03467a 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -59,6 +59,7 @@ "react": "^18.3.1", "react-day-picker": "^8.10.2", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.0", "react-json-view-lite": "^2.5.0", "react-router-dom": "^6.30.4", "react-superstore": "^0.1.4", @@ -74,6 +75,7 @@ "@types/node": "^20.19.42", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/typescript-estree": "^8.10.0", diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 04b1a99a..9f059f45 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { ChevronsUpDown, Flag, LayoutDashboard, + LayoutGrid, LucideIcon, Settings, } from "lucide-react"; @@ -298,6 +299,13 @@ const Sidebar = (props: SidebarProps) => { isSelected={props.selectedPage === "sessions"} isSidebarExpanded={props.isSidebarExpanded} /> +
diff --git a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx new file mode 100644 index 00000000..6c9f7830 --- /dev/null +++ b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx @@ -0,0 +1,104 @@ +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { + DashboardWidget, + SignalWidgetConfig, +} from "@/models/dashboard"; +import { GripVertical, Trash2 } from "lucide-react"; + +interface Props { + widget: DashboardWidget; + onRemove: () => void; + onConfigChange: (next: SignalWidgetConfig) => void; +} + +/** Widget shell — drag handle, title input, remove button, chart slot. + * The slot is a placeholder for PR #1; PR #2 wires up the actual + * SignalWidget chart so resizing this card visibly updates the chart. */ +export function DashboardWidgetCard({ widget, onRemove, onConfigChange }: Props) { + // For now only the `signal` type ships. Unknown types render a small + // placeholder so a forward-compat backend doesn't crash the page. + if (widget.type !== "signal") { + return ( + + +
+ Unsupported widget type. +
+
+ ); + } + + const config = widget.config as SignalWidgetConfig; + const setTitle = (title: string) => + onConfigChange({ ...config, title }); + + return ( + + +
+ {/* Placeholder chart pane. PR #2 swaps this for the real + SignalWidget chart driven by config.queries. */} +
+ + Queries + +
    + {config.queries.map((q, i) => ( +
  • + {q} +
  • + ))} +
+
+ Chart renders in a follow-up PR. +
+
+
+
+ ); +} + +function WidgetHeader({ + title, + onTitleChange, + onRemove, +}: { + title: string; + onTitleChange?: (next: string) => void; + onRemove: () => void; +}) { + return ( +
+ + {onTitleChange ? ( + onTitleChange(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + placeholder="Untitled widget" + className={cn( + "h-7 flex-1 border-0 bg-transparent px-1 text-sm font-medium shadow-none focus-visible:ring-0", + )} + /> + ) : ( + {title} + )} + +
+ ); +} 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/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..b0ee596f --- /dev/null +++ b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx @@ -0,0 +1,280 @@ +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 { + createWidget, + deleteWidget, + fetchDashboard, + updateDashboard, + updateWidget, +} from "@/lib/dashboards"; +import { notify } from "@/lib/notify"; +import { cn } from "@/lib/utils"; +import { + DEFAULT_WIDGET_SIZE, + type Dashboard, + type DashboardWidget, + type SignalWidgetConfig, +} from "@/models/dashboard"; +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"; + +const GRID_COLS = 12; +const ROW_HEIGHT = 30; +const MARGIN: [number, number] = [12, 12]; + +function DashboardDetailsPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [gridWidth, setGridWidth] = useState(1200); + + // 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 () => { + if (!dashboard) return; + const config: SignalWidgetConfig = { + title: "New signal widget", + queries: ["count(signal.name)"], + chart_type: "bar", + }; + // 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 ( + +
+ +
+
+ ); + } + + if (!dashboard) { + return ( + + + Dashboard not found. + + + ); + } + + return ( + +
+
+
+ + handleRename(e.target.value)} + className="h-8 max-w-[360px] text-lg font-semibold" + placeholder="Untitled dashboard" + /> +
+ + + Add widget + +
+ +
+ {dashboard.widgets.length === 0 ? ( + +

Empty dashboard.

+

+ Add a signal widget to start charting. Drag the title bar to + move, resize from any edge. +

+
+ ) : ( + + {dashboard.widgets.map((w) => ( +
+ handleRemoveWidget(w.id)} + onConfigChange={(config) => + handleUpdateWidgetConfig(w.id, config) + } + /> +
+ ))} +
+ )} +
+
+
+ ); +} + +export default DashboardDetailsPage; diff --git a/dashboard/src/pages/dashboards/DashboardsPage.tsx b/dashboard/src/pages/dashboards/DashboardsPage.tsx new file mode 100644 index 00000000..2737eb7a --- /dev/null +++ b/dashboard/src/pages/dashboards/DashboardsPage.tsx @@ -0,0 +1,201 @@ +import Layout from "@/components/Layout"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OutlineButton } from "@/components/ui/outline-button"; +import { getAxiosErrorMessage } from "@/lib/axios-error-handler"; +import { + createDashboard, + deleteDashboard, + fetchDashboards, +} from "@/lib/dashboards"; +import { notify } from "@/lib/notify"; +import type { Dashboard } from "@/models/dashboard"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +function DashboardsPage() { + const navigate = useNavigate(); + const [dashboards, setDashboards] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + reload(); + }, []); + + const reload = async () => { + try { + setDashboards(await fetchDashboards()); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const handleDelete = async (d: Dashboard) => { + // Plain confirm() is enough here — destructive but trivial to + // recreate, and we don't have a confirm-dialog pattern elsewhere + // on this page yet. + if (!confirm(`Delete "${d.name || "Untitled"}"? This can't be undone.`)) return; + try { + await deleteDashboard(d.id); + notify.success("Dashboard deleted"); + reload(); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + return ( + +
+
+

+ Custom widget grids over signal data. Drag, resize, save. +

+ + + + + New dashboard + + + { + setCreateOpen(false); + navigate(`/dashboards/${d.id}`); + }} + /> + +
+ + {dashboards.length === 0 ? ( + +

No dashboards yet.

+

+ Create one to start adding charts, gauges, and other widgets. +

+
+ ) : ( +
+ {dashboards.map((d) => ( + navigate(`/dashboards/${d.id}`)} + onDelete={() => handleDelete(d)} + /> + ))} +
+ )} +
+
+ ); +} + +function DashboardCard({ + dashboard, + onOpen, + onDelete, +}: { + dashboard: Dashboard; + onOpen: () => void; + onDelete: () => void; +}) { + return ( + +
+

+ {dashboard.name || "Untitled dashboard"} +

+ +
+ {dashboard.description ? ( +

+ {dashboard.description} +

+ ) : null} +

+ Updated {new Date(dashboard.updated_at).toLocaleString()} +

+
+ ); +} + +function CreateDashboardDialog({ + onCreated, +}: { + onCreated: (d: Dashboard) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + try { + const d = await createDashboard({ name, description }); + onCreated(d); + } catch (err) { + notify.error(getAxiosErrorMessage(err)); + setSubmitting(false); + } + }; + + return ( + + + New dashboard + +
+
+ + setName(e.target.value)} + placeholder="GR26 race telemetry" + required + autoFocus + /> +
+
+ + setDescription(e.target.value)} + placeholder="What this dashboard is for" + /> +
+ + {submitting ? "Creating…" : "Create"} + +
+
+ ); +} + +export default DashboardsPage; diff --git a/kerbecs.yaml b/kerbecs.yaml index a2d41ebb..94667a22 100644 --- a/kerbecs.yaml +++ b/kerbecs.yaml @@ -131,6 +131,22 @@ routes: strip_prefix: /api envelope: default + - name: dashboards + match: + path: /api/dashboards + upstream: vehicle + rewrite: + strip_prefix: /api + envelope: default + + - name: dashboards-id + match: + path: /api/dashboards/* + upstream: vehicle + rewrite: + strip_prefix: /api + envelope: default + - name: vehicle-types match: path: /api/vehicle-types diff --git a/mapache-go/dashboard.go b/mapache-go/dashboard.go new file mode 100644 index 00000000..87236b41 --- /dev/null +++ b/mapache-go/dashboard.go @@ -0,0 +1,51 @@ +package mapache + +import "time" + +// Dashboard is a user-curated collection of widgets arranged on a grid. +// Widgets are stored in a separate `dashboard_widget` table and joined +// via DashboardID; the loader populates Widgets here for API responses. +type Dashboard struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name"` + Description string `json:"description"` + // CreatedBy is the entity_id of the dashboard creator. Recorded for + // audit; access is currently global (any bearer-authenticated user + // can read or edit). + CreatedBy string `json:"created_by"` + Widgets []DashboardWidget `json:"widgets" gorm:"-"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;precision:6"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (Dashboard) TableName() string { + return "dashboard" +} + +// DashboardWidget is one rectangular pane on a dashboard. Type drives +// which renderer the frontend mounts; Config carries the renderer's +// type-specific settings as JSON (queries, chart type, axis overrides, +// color, etc.). X/Y/W/H are react-grid-layout coordinates. +type DashboardWidget struct { + ID string `json:"id" gorm:"primaryKey"` + DashboardID string `json:"dashboard_id" gorm:"index"` + // Type is the widget renderer key — e.g. "signal" for the MQL-driven + // chart, "gauge" for a single-value dial, "map" for a GPS trace. + // Unknown types render as a placeholder so a stale frontend + // doesn't crash on a newer backend's data. + Type string `json:"type"` + Config JSON `json:"config" gorm:"type:jsonb;default:'{}'"` + // react-grid-layout cell coordinates. The grid is 12 columns wide; + // rows are measured in "row units" (configured client-side, typically + // ~30px). H/W are in cells, X/Y are zero-based. + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;precision:6"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (DashboardWidget) TableName() string { + return "dashboard_widget" +} diff --git a/vehicle/api/api.go b/vehicle/api/api.go index d8943078..db67c2b5 100644 --- a/vehicle/api/api.go +++ b/vehicle/api/api.go @@ -67,6 +67,18 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/sessions/:sessionID/laps", GetLapsForSession) router.PUT("/sessions/:sessionID/laps", ReplaceLapsForSession) + // Dashboards: user-curated grids of widgets driven by the MQL query + // language. Widget endpoints are nested under their dashboard so the + // react-grid-layout drag/resize PUT can hit a stable path per widget. + router.GET("/dashboards", GetAllDashboards) + router.POST("/dashboards", CreateDashboard) + router.GET("/dashboards/:dashboardID", GetDashboardByID) + router.PUT("/dashboards/:dashboardID", UpdateDashboard) + router.DELETE("/dashboards/:dashboardID", DeleteDashboard) + router.POST("/dashboards/:dashboardID/widgets", CreateWidget) + router.PUT("/dashboards/:dashboardID/widgets/:widgetID", UpdateWidget) + router.DELETE("/dashboards/:dashboardID/widgets/:widgetID", DeleteWidget) + router.GET("/vehicle-types", GetVehicleTypes) // Config flag definitions (per vehicle type). diff --git a/vehicle/api/dashboard.go b/vehicle/api/dashboard.go new file mode 100644 index 00000000..0a7bec48 --- /dev/null +++ b/vehicle/api/dashboard.go @@ -0,0 +1,116 @@ +package api + +import ( + "net/http" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "github.com/gaucho-racing/mapache/vehicle/service" + ulid "github.com/gaucho-racing/ulid-go" + "github.com/gin-gonic/gin" +) + +func GetAllDashboards(c *gin.Context) { + c.JSON(http.StatusOK, service.GetAllDashboards()) +} + +func GetDashboardByID(c *gin.Context) { + d, err := service.GetDashboardByID(c.Param("dashboardID")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "dashboard not found"}) + return + } + c.JSON(http.StatusOK, d) +} + +func CreateDashboard(c *gin.Context) { + var input mapache.Dashboard + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = ulid.Make().Prefixed("dsh") + input.CreatedBy = c.GetString("Auth-UserID") + d, err := service.CreateDashboard(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, d) +} + +func UpdateDashboard(c *gin.Context) { + id := c.Param("dashboardID") + existing, err := service.GetDashboardByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "dashboard not found"}) + return + } + var input mapache.Dashboard + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + // Preserve immutable fields. Name/description are the only mutable + // dashboard-level fields; widgets are mutated via the widget endpoints. + input.ID = id + input.CreatedBy = existing.CreatedBy + input.CreatedAt = existing.CreatedAt + d, err := service.UpdateDashboard(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, d) +} + +func DeleteDashboard(c *gin.Context) { + if err := service.DeleteDashboard(c.Param("dashboardID")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "dashboard deleted"}) +} + +func CreateWidget(c *gin.Context) { + var input mapache.DashboardWidget + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = ulid.Make().Prefixed("wgt") + input.DashboardID = c.Param("dashboardID") + w, err := service.CreateWidget(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, w) +} + +// UpdateWidget is the hot endpoint — react-grid-layout's drag/resize +// fires a PUT every time the user releases a widget. The handler trusts +// the body to carry the full widget so the client can edit layout and +// config in one round-trip. +func UpdateWidget(c *gin.Context) { + var input mapache.DashboardWidget + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = c.Param("widgetID") + input.DashboardID = c.Param("dashboardID") + w, err := service.UpdateWidget(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, w) +} + +func DeleteWidget(c *gin.Context) { + if err := service.DeleteWidget(c.Param("widgetID")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "widget deleted"}) +} diff --git a/vehicle/database/db.go b/vehicle/database/db.go index 4028c879..58f3f1f7 100644 --- a/vehicle/database/db.go +++ b/vehicle/database/db.go @@ -33,6 +33,7 @@ func Init() { logger.SugarLogger.Infoln("Connected to database") db.AutoMigrate(&mapache.Vehicle{}, &mapache.Session{}, &mapache.Marker{}, &mapache.Lap{}, &mapache.Sector{}, + &mapache.Dashboard{}, &mapache.DashboardWidget{}, &model.ConfigFlag{}, &model.VehicleConfigOverride{}, &model.VehicleConfigStatus{}) logger.SugarLogger.Infoln("AutoMigration complete") DB = db diff --git a/vehicle/go.mod b/vehicle/go.mod index 9979d517..fc2e3b2a 100644 --- a/vehicle/go.mod +++ b/vehicle/go.mod @@ -2,6 +2,12 @@ module github.com/gaucho-racing/mapache/vehicle go 1.26 +// Pin mapache-go to the in-repo copy so adding model types to that +// shared package doesn't require a tag-and-release round-trip just to +// build the consuming services. Drop this and bump the version in +// `require` once mapache-go has been released with the new types. +replace github.com/gaucho-racing/mapache/mapache-go/v3 => ../mapache-go + require ( github.com/fatih/color v1.18.0 github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 diff --git a/vehicle/service/dashboard.go b/vehicle/service/dashboard.go new file mode 100644 index 00000000..f715d3fb --- /dev/null +++ b/vehicle/service/dashboard.go @@ -0,0 +1,75 @@ +package service + +import ( + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "github.com/gaucho-racing/mapache/vehicle/database" +) + +// GetAllDashboards returns every dashboard, most recently updated first. +// Widgets are NOT loaded — the list page only needs the metadata; widget +// payloads can be sizeable (jsonb config blobs per widget). +func GetAllDashboards() []mapache.Dashboard { + var dashboards []mapache.Dashboard + database.DB.Order("updated_at DESC").Find(&dashboards) + return dashboards +} + +// GetDashboardByID returns one dashboard with its widget list populated. +// Widgets are ordered by (y, x) so the response array matches reading +// order on the rendered grid — useful for keyboard navigation and for +// debugging without re-running the grid math. +func GetDashboardByID(id string) (mapache.Dashboard, error) { + var dashboard mapache.Dashboard + if err := database.DB.Where("id = ?", id).First(&dashboard).Error; err != nil { + return mapache.Dashboard{}, err + } + var widgets []mapache.DashboardWidget + database.DB.Where("dashboard_id = ?", id).Order("y, x").Find(&widgets) + dashboard.Widgets = widgets + return dashboard, nil +} + +func CreateDashboard(d mapache.Dashboard) (mapache.Dashboard, error) { + if err := database.DB.Create(&d).Error; err != nil { + return mapache.Dashboard{}, err + } + return d, nil +} + +func UpdateDashboard(d mapache.Dashboard) (mapache.Dashboard, error) { + // GORM's Save() does a full row upsert; we want it because the + // frontend can edit name + description in one request. + if err := database.DB.Save(&d).Error; err != nil { + return mapache.Dashboard{}, err + } + return d, nil +} + +func DeleteDashboard(id string) error { + // Widgets are deleted via the dashboard_id FK constraint at the + // service layer, not via GORM cascade — keeping the foreign-key + // behavior explicit so a dashboard delete doesn't silently orphan + // rows if the constraint is ever dropped. + if err := database.DB.Where("dashboard_id = ?", id).Delete(&mapache.DashboardWidget{}).Error; err != nil { + return err + } + return database.DB.Where("id = ?", id).Delete(&mapache.Dashboard{}).Error +} + +func CreateWidget(w mapache.DashboardWidget) (mapache.DashboardWidget, error) { + if err := database.DB.Create(&w).Error; err != nil { + return mapache.DashboardWidget{}, err + } + return w, nil +} + +func UpdateWidget(w mapache.DashboardWidget) (mapache.DashboardWidget, error) { + if err := database.DB.Save(&w).Error; err != nil { + return mapache.DashboardWidget{}, err + } + return w, nil +} + +func DeleteWidget(id string) error { + return database.DB.Where("id = ?", id).Delete(&mapache.DashboardWidget{}).Error +} From ca2778a8d80ea58adfef9643094137f2513305dd Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 08:17:12 -0400 Subject: [PATCH 2/3] feat(dashboards): chart rendering + rolling refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked onto PR #1's foundation: SignalWidget — opt-in embedded mode: - New seedQueries / onQueriesChange props prefill the chip rows from saved widget config and bubble subsequent edits back to the parent. - New seedChartType / onChartTypeChange follow the same contract for the chart-type select. - New refreshIntervalSec prop: when set, the widget re-runs its query every N seconds by folding a monotonic tick into the existing `fetchKey`. The chart's right edge keeps tracking `now` without holding a long-lived stream open. PR-N can drop this for a real SSE-driven aggregation hook against /live/sse for sub-second updates. DashboardWidgetCard: - Embeds the full SignalWidget when type=signal, wired with seed/change props so the widget's queries + chart type persist back through /dashboards/:id/widgets/:wid PUT. - The dashboard's shell (drag handle, title, remove) wraps the SignalWidget so the kebab's own delete/hide actions defer to the outer card. DashboardDetailsPage: - Page-level TimeframePicker (reused from the Signals page) drives every widget. `isRolling` is derived from the timeframe ("Past N" presets or any custom range ending within 5s of now) and flows into each widget; when true, refreshIntervalSec activates 5s polling. - Fetches signal names once per vehicle for the chip builder's autocomplete inside every embedded widget. - ECharts connect group shared across the dashboard so hover/tooltip/ dataZoom sync between panels. Out of scope (follow-ups): - True SSE-driven streaming + client-side incremental aggregation — needed for sub-second blends. The refresh-every-5s pattern here is the smallest end-to-end working version. - Specialty widget types (gauge, big-number, dedicated map) — the widget registry is type-keyed so these slot in cleanly later. - Per-widget timeframe override + per-widget settings dialog. --- .../dashboards/DashboardWidgetCard.tsx | 163 +++++++++++------- .../src/components/signals/SignalWidget.tsx | 76 +++++++- .../pages/dashboards/DashboardDetailsPage.tsx | 82 ++++++++- vehicle/go.sum | 2 - 4 files changed, 249 insertions(+), 74 deletions(-) diff --git a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx index 6c9f7830..e7389726 100644 --- a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx +++ b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx @@ -1,104 +1,143 @@ -import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { SignalWidget } from "@/components/signals/SignalWidget"; import { cn } from "@/lib/utils"; import type { DashboardWidget, SignalWidgetConfig, } from "@/models/dashboard"; +import type { Lap } from "@/models/session"; import { GripVertical, Trash2 } from "lucide-react"; +import type { ChartType } from "@/components/signals/ChartTypeToggle"; interface Props { widget: DashboardWidget; + vehicleId: string; + vehicleType: string; + signalNames: string[]; + startIso: string; + endIso: string; + rangeSeconds: number; + /** True when the window's right edge tracks `now` (any "Past N" + * preset, or a custom range ending within seconds of now). Live- + * blending widgets watch this to know when to open a stream. */ + isRolling: boolean; + /** ECharts connect group shared across all widgets on this dashboard + * so hover/tooltip/dataZoom syncs between panels. */ + groupId: string; + laps?: Lap[] | null; onRemove: () => void; onConfigChange: (next: SignalWidgetConfig) => void; } -/** Widget shell — drag handle, title input, remove button, chart slot. - * The slot is a placeholder for PR #1; PR #2 wires up the actual - * SignalWidget chart so resizing this card visibly updates the chart. */ -export function DashboardWidgetCard({ widget, onRemove, onConfigChange }: Props) { - // For now only the `signal` type ships. Unknown types render a small - // placeholder so a forward-compat backend doesn't crash the page. +/** Widget shell + embedded renderer. The shell carries the drag handle, + * title input, and remove button; the renderer plugs in by widget type + * ("signal" reuses SignalWidget; future types fan out from here). */ +export function DashboardWidgetCard({ + widget, + vehicleId, + vehicleType, + signalNames, + startIso, + endIso, + rangeSeconds, + isRolling, + groupId, + laps, + onRemove, + onConfigChange, +}: Props) { if (widget.type !== "signal") { return ( - - +
- Unsupported widget type. + Unsupported widget type — update the dashboard to render it.
-
+ ); } const config = widget.config as SignalWidgetConfig; - const setTitle = (title: string) => + const handleTitleChange = (title: string) => onConfigChange({ ...config, title }); + const handleQueriesChange = (queries: string[]) => + onConfigChange({ ...config, queries }); + const handleChartTypeChange = (chart_type: ChartType) => + onConfigChange({ ...config, chart_type }); return ( - - -
- {/* Placeholder chart pane. PR #2 swaps this for the real - SignalWidget chart driven by config.queries. */} -
- - Queries - -
    - {config.queries.map((q, i) => ( -
  • - {q} -
  • - ))} -
-
- Chart renders in a follow-up PR. -
-
+ +
+
- +
); } -function WidgetHeader({ +function WidgetShell({ title, onTitleChange, onRemove, + children, }: { title: string; onTitleChange?: (next: string) => void; onRemove: () => void; + children: React.ReactNode; }) { return ( -
- - {onTitleChange ? ( - onTitleChange(e.target.value)} +
+
+ + {onTitleChange ? ( + onTitleChange(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + placeholder="Untitled widget" + className={cn( + "h-7 flex-1 border-0 bg-transparent px-1 text-sm font-medium shadow-none focus-visible:ring-0", + )} + /> + ) : ( + {title} + )} + + aria-label="Remove widget" + className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive" + > + + +
+ {children}
); } diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index 9b13ec47..b65e1eee 100644 --- a/dashboard/src/components/signals/SignalWidget.tsx +++ b/dashboard/src/components/signals/SignalWidget.tsx @@ -83,7 +83,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 +207,24 @@ 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`. Dashboards use this to blend live + historical + * data without (yet) holding a streaming subscription open. */ + refreshIntervalSec?: number; } export function SignalWidget({ @@ -225,14 +243,26 @@ export function SignalWidget({ interactionMode, onInteractionModeChange, laps, + seedQueries, + onQueriesChange, + seedChartType, + onChartTypeChange, + refreshIntervalSec, }: 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 +270,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 +361,33 @@ export function SignalWidget({ () => fetchPlan.filter((p) => p.runnable), [fetchPlan], ); + // Rolling-window refresh: when `refreshIntervalSec` is set, bump a + // tick every N seconds. The tick gets folded into `fetchKey`, which + // is what the historical query effect watches — so the chart re-runs + // the query, picking up rows added since the last fetch. This is the + // poor-person's live blend; PR-N will swap in an SSE-driven hook for + // sub-second updates. + const [refreshTick, setRefreshTick] = useState(0); + useEffect(() => { + if (!refreshIntervalSec || refreshIntervalSec <= 0) return; + const t = setInterval( + () => setRefreshTick((n) => n + 1), + refreshIntervalSec * 1000, + ); + return () => clearInterval(t); + }, [refreshIntervalSec]); + // 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 ? refreshTick : 0, }), - [runnableFetches, interval], + [runnableFetches, interval, refreshIntervalSec, 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/ diff --git a/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx index b0ee596f..45b84229 100644 --- a/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx +++ b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx @@ -3,6 +3,7 @@ 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, @@ -12,12 +13,14 @@ import { } 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"; @@ -25,17 +28,73 @@ 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 { + defaultTimeframe, + type Timeframe, + TimeframePicker, +} from "@/components/signals/TimeframePicker"; 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([]); + + 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. @@ -230,10 +289,17 @@ function DashboardDetailsPage() { placeholder="Untitled dashboard" />
- - - Add widget - +
+ + + + Add widget + +
@@ -262,6 +328,14 @@ function DashboardDetailsPage() {
handleRemoveWidget(w.id)} onConfigChange={(config) => handleUpdateWidgetConfig(w.id, config) diff --git a/vehicle/go.sum b/vehicle/go.sum index 0aab583a..a7929d83 100644 --- a/vehicle/go.sum +++ b/vehicle/go.sum @@ -13,8 +13,6 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 h1:sihPvHGJ9CgkoqBySJnjZ1IKoHi9kqh0l1w8nAyNDRE= -github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0/go.mod h1:2Zb3ztikLtk3UMS0/bg2nhwroSoyFWvQa/tqVGvYFlc= github.com/gaucho-racing/ulid-go v1.1.0 h1:x00XM8EjlegfhlLYIob+U8ba5iX0gDRUr8mgBsjCunk= github.com/gaucho-racing/ulid-go v1.1.0/go.mod h1:HwqoC27UtvXHrmhTO7K2GnXZ1VAeR6tg6EjrSEP5JUU= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= From 22d1b1048bd9d86d2aea5d5e8543567bcda11ad1 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 08:32:38 -0400 Subject: [PATCH 3/3] feat(dashboards): SSE streaming, chart-only widgets, edit dialog, picker drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled UX moves on top of the previous chart-rendering commit: 1) SSE-driven live blending - New `useLiveTrigger` hook opens an EventSource against the live service's /live/sse endpoint with the signal patterns pulled out of each query's `where` clause. On each arrival it bumps a throttled `tick` (default 500ms). The tick folds into SignalWidget's existing fetchKey, so /query/run re-fires the moment a relevant sample lands — the backend stays the source of truth for bucket math (aggregator semantics differ enough across count/sum/avg/min/max/ last/p50/p95/p99/stddev that mirroring them client-side would have been gnarly + bug-prone). - Queries with no `where(name = "...")` filter don't subscribe; we'd otherwise have to listen to every signal on the wire. Add a name filter to opt into streaming. - The 5s setInterval refresh stays as a heartbeat — guarantees the chart still moves forward through signal lulls. 2) Chart-only widget + edit dialog - New `chartOnly` prop on SignalWidget suppresses the chip-builder header and chart-type picker, leaving just the canvas. The Card wrapper drops its border/shadow so the widget reads as inline. - DashboardWidgetCard renders the chart-only SignalWidget in the grid cell and a Pencil button in the card header opens a Dialog containing the full-fidelity SignalWidget (chip rows, chart type toggle, all the knobs). Card and dialog never both mount at the same time, so their internal query state can't drift apart; the card's SignalWidget is keyed on the persisted config so the close- after-edit path picks up the new seed. 3) Add-widget drawer - New `Sheet` UI primitive (Radix Dialog with side-anchored animation, right-edge variant). Reusable for any future right- edge panel. - `AddWidgetDrawer` lists every chart type from the existing CHART_TYPES registry with a one-line description per entry; the dashboard's "Add widget" button now opens the drawer instead of dropping a default bar widget directly. PR-N will extend the list with specialty widget types (gauge, big-number, dedicated map). --- dashboard/package-lock.json | 479 +++--------------- .../components/dashboards/AddWidgetDrawer.tsx | 88 ++++ .../dashboards/DashboardWidgetCard.tsx | 145 ++++-- .../src/components/signals/SignalWidget.tsx | 54 +- dashboard/src/components/ui/sheet.tsx | 105 ++++ dashboard/src/lib/useLiveTrigger.ts | 108 ++++ .../pages/dashboards/DashboardDetailsPage.tsx | 20 +- query/uv.lock | 103 +--- 8 files changed, 537 insertions(+), 565 deletions(-) create mode 100644 dashboard/src/components/dashboards/AddWidgetDrawer.tsx create mode 100644 dashboard/src/components/ui/sheet.tsx create mode 100644 dashboard/src/lib/useLiveTrigger.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 159b34ef..11ce8661 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "mp_dashboard", - "version": "3.9.0", + "version": "3.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mp_dashboard", - "version": "3.9.0", + "version": "3.9.4", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-brands-svg-icons": "6.6.0", @@ -55,6 +55,7 @@ "react": "^18.3.1", "react-day-picker": "^8.10.2", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.0", "react-json-view-lite": "^2.5.0", "react-router-dom": "^6.30.4", "react-superstore": "^0.1.4", @@ -70,6 +71,7 @@ "@types/node": "^20.19.42", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/typescript-estree": "^8.10.0", @@ -83,8 +85,7 @@ "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.4.21", - "vitest": "^2.1.9" + "vite": "^5.4.21" } }, "node_modules/@alloc/quick-lru": { @@ -2772,6 +2773,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -3314,119 +3325,6 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@xyflow/react": { "version": "12.11.0", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", @@ -3595,16 +3493,6 @@ "node": ">=8" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3751,16 +3639,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3812,23 +3690,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3851,16 +3712,6 @@ "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4279,16 +4130,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4443,13 +4284,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4723,16 +4557,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4748,16 +4572,6 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5486,13 +5300,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lucide-react": { "version": "0.453.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", @@ -5502,16 +5309,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/mapbox-gl": { "version": "3.24.0", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.0.tgz", @@ -5935,23 +5732,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pbf": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", @@ -6368,6 +6148,44 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout/node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6456,6 +6274,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.30.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", @@ -6622,6 +6454,12 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6803,13 +6641,6 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6845,20 +6676,6 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7051,20 +6868,6 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7110,42 +6913,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7609,95 +7382,6 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7713,23 +7397,6 @@ "node": ">= 8" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", 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 index e7389726..b8dd011f 100644 --- a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx +++ b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx @@ -1,12 +1,20 @@ +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, Trash2 } from "lucide-react"; +import { GripVertical, Pencil, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; import type { ChartType } from "@/components/signals/ChartTypeToggle"; interface Props { @@ -17,21 +25,18 @@ interface Props { startIso: string; endIso: string; rangeSeconds: number; - /** True when the window's right edge tracks `now` (any "Past N" - * preset, or a custom range ending within seconds of now). Live- - * blending widgets watch this to know when to open a stream. */ isRolling: boolean; - /** ECharts connect group shared across all widgets on this dashboard - * so hover/tooltip/dataZoom syncs between panels. */ groupId: string; laps?: Lap[] | null; onRemove: () => void; onConfigChange: (next: SignalWidgetConfig) => void; } -/** Widget shell + embedded renderer. The shell carries the drag handle, - * title input, and remove button; the renderer plugs in by widget type - * ("signal" reuses SignalWidget; future types fan out from here). */ +/** 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, @@ -46,9 +51,14 @@ export function DashboardWidgetCard({ onRemove, onConfigChange, }: Props) { + const [editing, setEditing] = useState(false); + if (widget.type !== "signal") { return ( - +
Unsupported widget type — update the dashboard to render it.
@@ -64,48 +74,92 @@ export function DashboardWidgetCard({ 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 && ( + + )} +
+
+ + + + + Edit widget + + {editing && ( + + )} + + + ); } function WidgetShell({ title, onTitleChange, + onEdit, onRemove, children, }: { title: string; onTitleChange?: (next: string) => void; + onEdit?: () => void; onRemove: () => void; children: React.ReactNode; }) { @@ -127,6 +181,17 @@ function WidgetShell({ ) : ( {title} )} + {onEdit && ( + + )}