Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
479 changes: 73 additions & 406 deletions dashboard/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ChevronsUpDown,
Flag,
LayoutDashboard,
LayoutGrid,
LucideIcon,
Settings,
} from "lucide-react";
Expand Down Expand Up @@ -298,6 +299,13 @@ const Sidebar = (props: SidebarProps) => {
isSelected={props.selectedPage === "sessions"}
isSidebarExpanded={props.isSidebarExpanded}
/>
<SidebarItem
icon={LayoutGrid}
text="Dashboards"
link={`/dashboards`}
isSelected={props.selectedPage === "dashboards"}
isSidebarExpanded={props.isSidebarExpanded}
/>
<div className="px-4 py-2">
<Separator />
</div>
Expand Down
88 changes: 88 additions & 0 deletions dashboard/src/components/dashboards/AddWidgetDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<SheetTitle>Add widget</SheetTitle>
<SheetDescription>
Pick a chart type to drop on the dashboard. Every widget is
driven by an MQL query — you can edit it after.
</SheetDescription>
</SheetHeader>

<div className="-mr-2 mt-4 flex flex-col gap-2 overflow-y-auto pr-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Charts
</span>
<div className="grid grid-cols-2 gap-2">
{CHART_TYPES.map((t) => (
<button
key={t.type}
type="button"
onClick={() => onPick(t.type)}
className={cn(
"flex flex-col items-start gap-2 rounded-md border bg-background p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="flex items-center gap-2">
<t.icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t.label}</span>
</div>
<p className="text-[11px] leading-snug text-muted-foreground">
{describeChartType(t.type)}
</p>
</button>
))}
</div>
</div>
</SheetContent>
</Sheet>
);
}

// 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.";
}
}
208 changes: 208 additions & 0 deletions dashboard/src/components/dashboards/DashboardWidgetCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WidgetShell
title={`Unknown widget: ${widget.type}`}
onRemove={onRemove}
>
<div className="flex flex-1 items-center justify-center p-4 text-xs text-muted-foreground">
Unsupported widget type — update the dashboard to render it.
</div>
</WidgetShell>
);
}

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<string>();
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 (
<>
<WidgetShell
title={config.title ?? ""}
onTitleChange={handleTitleChange}
onEdit={() => setEditing(true)}
onRemove={onRemove}
>
<div className="min-h-0 flex-1 overflow-hidden p-2">
{/* 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 && (
<SignalWidget
key={`${config.queries.join("|")}::${config.chart_type ?? "bar"}`}
{...sharedSignalWidgetProps}
chartOnly
/>
)}
</div>
</WidgetShell>

<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-auto">
<DialogHeader>
<DialogTitle>Edit widget</DialogTitle>
</DialogHeader>
{editing && (
<SignalWidget {...sharedSignalWidgetProps} />
)}
</DialogContent>
</Dialog>
</>
);
}

function WidgetShell({
title,
onTitleChange,
onEdit,
onRemove,
children,
}: {
title: string;
onTitleChange?: (next: string) => void;
onEdit?: () => void;
onRemove: () => void;
children: React.ReactNode;
}) {
return (
<div className="flex h-full flex-col rounded-lg border bg-card">
<div className="widget-drag-handle flex cursor-grab items-center gap-2 border-b px-3 py-2 active:cursor-grabbing">
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/50" />
{onTitleChange ? (
<Input
defaultValue={title}
onBlur={(e) => 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",
)}
/>
) : (
<span className="flex-1 truncate text-sm font-medium">{title}</span>
)}
{onEdit && (
<button
type="button"
onClick={onEdit}
onMouseDown={(e) => e.stopPropagation()}
aria-label="Edit widget"
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={onRemove}
onMouseDown={(e) => e.stopPropagation()}
aria-label="Remove widget"
className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{children}
</div>
);
}
Loading
Loading