diff --git a/.gitignore b/.gitignore index 4b39a6d..494128a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ notes/burnlists/ dist/ dashboard/dist/ node_modules/ +.oven-test-*/ .playwright-cli/ output/ build/ *.zip *.tgz *.log +ovens/**/*.ir.json diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 7ed20be..5389018 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -44,12 +44,12 @@ if (args[0] === "install" || args[0] === "uninstall") { } if (args[0] === "differential-testing" && args[1] === "schema") { - console.log(resolve(packageRoot, "ovens", "differential-testing", "schema", "differential-testing-data.schema.json")); + console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "data.schema.json")); return; } if (args[0] === "differential-testing" && args[1] === "sdk") { - console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "differential-testing-adapter-sdk.mjs")); + console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "adapter-sdk.mjs")); return; } @@ -62,11 +62,11 @@ if (args[0] === "differential-testing" && ["validate", "validate-bundle"].includ const path = resolve(process.cwd(), args[2]); const document = JSON.parse(readFileSync(path, "utf8")); if (document?.schema === "burnlist-differential-testing-bundle@1") { - const { assertDifferentialTestingBundle } = await import("../ovens/differential-testing/engine/differential-testing-transport.mjs"); + const { assertDifferentialTestingBundle } = await import("../ovens/differential-testing/engine/transport.mjs"); const bundle = assertDifferentialTestingBundle(path); console.log(`Valid Differential Testing bundle: ${bundle.scenarios.length} scenarios; selected ${bundle.selectedScenarioId ?? "none"}.`); } else { - const { assertDifferentialTestingData } = await import("../ovens/differential-testing/engine/differential-testing-data-contract.mjs"); + const { assertDifferentialTestingData } = await import("../ovens/differential-testing/engine/data-contract.mjs"); assertDifferentialTestingData(document); const sampleCount = document.fields.reduce((total, field) => total + field.sampleCount, 0); console.log(`Valid Differential Testing data: ${document.fields.length} fields, ${sampleCount} samples, ${document.summary.frames.uniqueTicks} aligned ticks.`); diff --git a/dashboard/oven-ir-compile.mjs b/dashboard/oven-ir-compile.mjs new file mode 100644 index 0000000..f2a05dc --- /dev/null +++ b/dashboard/oven-ir-compile.mjs @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileOven } from "../src/ovens/dsl/oven-compile.mjs"; + +const dashboardDir = fileURLToPath(new URL(".", import.meta.url)); +const repoRoot = resolve(dashboardDir, ".."); + +export function compileOvenIrForJsonPath(irJsonAbsPath) { + if (!irJsonAbsPath.endsWith(".ir.json")) { + throw new Error(`Expected an .ir.json path, got ${irJsonAbsPath}`); + } + + const ovenPath = `${irJsonAbsPath.slice(0, -".ir.json".length)}.oven`; + const file = relative(repoRoot, ovenPath).split(sep).join("/"); + const compiled = compileOven(readFileSync(ovenPath, "utf8"), { file }); + if (!compiled.ok) { + throw new Error(`Failed to compile ${file}: ${JSON.stringify(compiled.diagnostics)}`); + } + return compiled.ir; +} diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2513e60..488a6a7 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Clock3, ListChecks } from "lucide-react"; -import { AppHeader, ChecklistDashboard, DashboardError, DifferentialTestingPage, EmptyState, FILTERS, Filters, NewOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +import { AppHeader, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, NewOvenPage, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; @@ -24,10 +24,10 @@ export function App() { return (
-
- {section === "differential-testing" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "new-oven" ? : section === "run-burn" ? : selected ? ( +
+ {section === "differential-testing" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : selected ? ( error ? : loading && !progress ? : progress ? ( - + ) : ) : (
diff --git a/dashboard/src/components/BurnOvens/BurnOvens.tsx b/dashboard/src/components/BurnOvens/BurnOvens.tsx index 4b100b6..bbb5d1b 100644 --- a/dashboard/src/components/BurnOvens/BurnOvens.tsx +++ b/dashboard/src/components/BurnOvens/BurnOvens.tsx @@ -1,28 +1,12 @@ import { type FormEvent, - type PointerEvent as ReactPointerEvent, useEffect, - useMemo, - useRef, useState, } from "react"; import { - Activity, ArrowLeft, - ChartBar, - ChartLine, - ChartPie, - Eye, - Gauge, - GitCompareArrows, - LayoutGrid, - Pencil, Play, - Plus, Save, - Table2, - Trash2, - X, } from "lucide-react"; import { Button } from "@layout"; import { ovenActionUrl, ovenCatalogKey, ovenTargetRepoRoot } from "@lib/oven-identity.mjs"; @@ -35,54 +19,7 @@ import { CardTitle, } from "@layout"; -type DetailSection = { - id: string; - title: string; - description: string; - widget: DetailType; - source: string; - format: "plain"; - column: number; - row: number; - columnSpan: number; - rowSpan: number; -}; - -type OvenDetail = { - version: 1; - columns: number; - rows: number; - rowHeight: number; - cells: DetailSection[]; -}; - -type GridPoint = { row: number; column: number }; -type GridRect = { - row: number; - column: number; - rowSpan: number; - columnSpan: number; -}; -type DraftArea = GridRect & { - description: string; - widget: DetailType; -}; - -const DETAIL_TYPES = [ - { value: "metric", label: "Metric", Icon: Gauge }, - { value: "progress", label: "Progress", Icon: Activity }, - { value: "line-chart", label: "Line chart", Icon: ChartLine }, - { value: "bar-chart", label: "Bar chart", Icon: ChartBar }, - { value: "pie-chart", label: "Pie chart", Icon: ChartPie }, - { value: "table", label: "Table", Icon: Table2 }, - { value: "comparison", label: "Comparison", Icon: GitCompareArrows }, -] as const; - -type DetailType = (typeof DETAIL_TYPES)[number]["value"]; -const NEW_OVEN_ROW_HEIGHT = 50; - const fieldClass = "form-control"; -const compactFieldClass = "form-control form-control-compact"; function slug(value: string) { return value @@ -93,77 +30,6 @@ function slug(value: string) { .slice(0, 48); } -function bounds(start: GridPoint, end: GridPoint): GridRect { - return { - column: Math.min(start.column, end.column), - row: Math.min(start.row, end.row), - columnSpan: Math.abs(start.column - end.column) + 1, - rowSpan: Math.abs(start.row - end.row) + 1, - }; -} - -function overlaps(left: GridRect, right: GridRect) { - return ( - left.column < right.column + right.columnSpan - && left.column + left.columnSpan > right.column - && left.row < right.row + right.rowSpan - && left.row + left.rowSpan > right.row - ); -} - -function gridAreaStyle(rect: GridRect) { - return { - gridColumn: String(rect.column) + " / span " + String(rect.columnSpan), - gridRow: String(rect.row) + " / span " + String(rect.rowSpan), - }; -} - -function detailTypeDefinition(value: DetailType) { - return DETAIL_TYPES.find((detailType) => detailType.value === value) - || DETAIL_TYPES[0]; -} - -function deriveSectionTitle(description: string, widget: DetailType) { - const firstLine = description - .split(/\r?\n/u) - .map((line) => line.trim()) - .find(Boolean); - return (firstLine || detailTypeDefinition(widget).label).slice(0, 80); -} - -function DetailTypePicker({ - value, - onChange, -}: { - value: DetailType; - onChange: (value: DetailType) => void; -}) { - return ( -
- {DETAIL_TYPES.map(({ value: option, label, Icon }) => { - const selected = option === value; - return ( - - ); - })} -
- ); -} - function PageHeader({ title, description, @@ -189,365 +55,6 @@ function PageHeader({ ); } -function DetailSkeletonBuilder({ - detail, - onChange, - onError, -}: { - detail: OvenDetail; - onChange: (detail: OvenDetail) => void; - onError: (message: string) => void; -}) { - const gridRef = useRef(null); - const descriptionRef = useRef(null); - const gestureRef = useRef<{ - pointerId: number; - start: GridPoint; - end: GridPoint; - } | null>(null); - const nextCell = useRef(1); - const [dragArea, setDragArea] = useState(null); - const [draft, setDraft] = useState(null); - const [editingId, setEditingId] = useState(null); - - const trackCells = useMemo( - () => Array.from( - { length: detail.rows * detail.columns }, - (_, index) => ({ - row: Math.floor(index / detail.columns) + 1, - column: (index % detail.columns) + 1, - }), - ), - [detail.columns, detail.rows], - ); - useEffect(() => { - if (draft) descriptionRef.current?.focus(); - }, [draft]); - - const pointFromPointer = (clientX: number, clientY: number): GridPoint | null => { - const grid = gridRef.current; - if (!grid) return null; - const rect = grid.getBoundingClientRect(); - const width = Math.max(1, grid.clientWidth); - const height = detail.rows * detail.rowHeight; - const localX = Math.max(0, Math.min(width - 0.001, clientX - rect.left)); - const localY = Math.max(0, Math.min(height - 0.001, clientY - rect.top)); - return { - column: Math.floor(localX / (width / detail.columns)) + 1, - row: Math.floor(localY / detail.rowHeight) + 1, - }; - }; - - const releasePointer = (pointerId: number) => { - const grid = gridRef.current; - if (grid?.hasPointerCapture(pointerId)) { - grid.releasePointerCapture(pointerId); - } - }; - - const clearGesture = () => { - const gesture = gestureRef.current; - if (gesture) releasePointer(gesture.pointerId); - gestureRef.current = null; - setDragArea(null); - }; - - const startDrag = (event: ReactPointerEvent) => { - if (event.button !== 0) return; - const target = event.target as HTMLElement; - if (!target.closest("[data-oven-grid-cell]")) return; - const point = pointFromPointer(event.clientX, event.clientY); - if (!point) return; - if (detail.cells.some((cell) => overlaps(cell, { - ...point, - columnSpan: 1, - rowSpan: 1, - }))) return; - - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - gestureRef.current = { - pointerId: event.pointerId, - start: point, - end: point, - }; - setDraft(null); - setEditingId(null); - setDragArea(bounds(point, point)); - onError(""); - }; - - const moveDrag = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId) return; - const point = pointFromPointer(event.clientX, event.clientY); - if (!point) return; - event.preventDefault(); - gesture.end = point; - setDragArea(bounds(gesture.start, point)); - }; - - const finishDrag = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId) return; - const point = pointFromPointer(event.clientX, event.clientY) || gesture.end; - const selected = bounds(gesture.start, point); - clearGesture(); - - if (selected.columnSpan === 1 && selected.rowSpan === 1) { - onError("Drag across at least two cells to add a detail section."); - return; - } - if (detail.cells.some((cell) => overlaps(cell, selected))) { - onError("That section overlaps an existing detail section."); - return; - } - - setDraft({ - ...selected, - description: "", - widget: "metric", - }); - onError(""); - }; - - const cancelDrag = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId) return; - clearGesture(); - }; - - const addDraft = () => { - if (!draft) return; - const description = draft.description.trim(); - if (!description) { - onError("Describe the metric before adding the detail section."); - descriptionRef.current?.focus(); - return; - } - - const usedIds = new Set(detail.cells.map((cell) => cell.id)); - let id = "panel-" + String(nextCell.current++); - while (usedIds.has(id)) id = "panel-" + String(nextCell.current++); - const cell: DetailSection = { - id, - title: deriveSectionTitle(description, draft.widget), - description, - widget: draft.widget, - source: "", - format: "plain", - column: draft.column, - row: draft.row, - columnSpan: draft.columnSpan, - rowSpan: draft.rowSpan, - }; - onChange({ ...detail, cells: [...detail.cells, cell] }); - setDraft(null); - setEditingId(id); - onError(""); - }; - - const updateCell = (id: string, patch: Partial) => { - onChange({ - ...detail, - cells: detail.cells.map((cell) => ( - cell.id === id ? { ...cell, ...patch } : cell - )), - }); - }; - - const deleteCell = (id: string) => { - onChange({ - ...detail, - cells: detail.cells.filter((cell) => cell.id !== id), - }); - if (editingId === id) setEditingId(null); - onError(""); - }; - - return ( - - - - - Detail skeleton - - - Build the Oven's detail page template by placing sections directly on the skeleton. - - - -
- {trackCells.map((point) => ( -