diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py index 736a40e967..8a069cf21b 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -128,7 +128,7 @@ def validate_recipe(recipe: dict[str, Any]) -> None: def preview_recipe( recipe: dict[str, Any], num_records: int, -) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: +) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]: builder = build_config_builder(recipe) designer = create_data_designer(recipe) results = designer.preview(builder, num_records=num_records) @@ -143,5 +143,10 @@ def preview_recipe( if results.processor_artifacts is None else _to_jsonable(results.processor_artifacts) ) + analysis = ( + None + if results.analysis is None + else _to_jsonable(results.analysis.model_dump(mode="json")) + ) - return dataset, artifacts + return dataset, artifacts, analysis diff --git a/studio/backend/models/data_recipe.py b/studio/backend/models/data_recipe.py index a906586552..885b6470fe 100644 --- a/studio/backend/models/data_recipe.py +++ b/studio/backend/models/data_recipe.py @@ -18,6 +18,7 @@ class RecipePayload(BaseModel): class PreviewResponse(BaseModel): dataset: list[dict[str, Any]] = Field(default_factory=list) processor_artifacts: dict[str, Any] | None = None + analysis: dict[str, Any] | None = None class ValidateError(BaseModel): @@ -34,4 +35,3 @@ class ValidateResponse(BaseModel): class JobCreateResponse(BaseModel): job_id: str - diff --git a/studio/backend/routes/data_recipe.py b/studio/backend/routes/data_recipe.py index 90c78d16ef..fd73022a9f 100644 --- a/studio/backend/routes/data_recipe.py +++ b/studio/backend/routes/data_recipe.py @@ -57,13 +57,13 @@ def preview(payload: RecipePayload) -> PreviewResponse: num_records = int(run.get("rows") or 5) try: - dataset, artifacts = preview_recipe(recipe, num_records) + dataset, artifacts, analysis = preview_recipe(recipe, num_records) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - return PreviewResponse(dataset=dataset, processor_artifacts=artifacts) + return PreviewResponse(dataset=dataset, processor_artifacts=artifacts, analysis=analysis) @router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse) diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index ebf3b22c00..10087b33be 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -1,4 +1,4 @@ -const DEFAULT_BASE = ""; +const DEFAULT_BASE = "/api/data-recipe"; export const DATA_DESIGNER_API_BASE = import.meta.env.VITE_DATA_DESIGNER_API ?? DEFAULT_BASE; @@ -7,6 +7,7 @@ export type PreviewResponse = { dataset?: unknown[]; // biome-ignore lint/style/useNamingConvention: api schema processor_artifacts?: Record; + analysis?: Record; }; export type ValidateError = { diff --git a/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx b/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx new file mode 100644 index 0000000000..51dc812575 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx @@ -0,0 +1,223 @@ +import { useMemo, type ReactElement } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import type { RecipeExecutionRecord } from "../../execution-types"; + +type ExecutionsViewProps = { + executions: RecipeExecutionRecord[]; + selectedExecutionId: string | null; + currentSignature: string; + previewLoading: boolean; + onSelectExecution: (id: string) => void; + onRunPreview: () => void; +}; + +function formatTimestamp(value: number): string { + return new Date(value).toLocaleString(); +} + +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) { + return "--"; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function statusTone(status: RecipeExecutionRecord["status"]): string { + if (status === "completed") { + return "bg-emerald-100 text-emerald-700"; + } + if (status === "error") { + return "bg-red-100 text-red-700"; + } + return "bg-amber-100 text-amber-700"; +} + +export function ExecutionsView({ + executions, + selectedExecutionId, + currentSignature, + previewLoading, + onSelectExecution, + onRunPreview, +}: ExecutionsViewProps): ReactElement { + const selectedExecution = useMemo( + () => + executions.find((execution) => execution.id === selectedExecutionId) ?? + null, + [executions, selectedExecutionId], + ); + const isStale = Boolean( + selectedExecution && + selectedExecution.recipeSignature.length > 0 && + selectedExecution.recipeSignature !== currentSignature, + ); + + const tableColumns = useMemo>[]>(() => { + if (!selectedExecution) { + return []; + } + const names = new Set(); + for (const row of selectedExecution.dataset) { + for (const key of Object.keys(row)) { + names.add(key); + } + } + return Array.from(names).map((name) => ({ + accessorKey: name, + header: name, + cell: ({ getValue }) => { + const value = getValue(); + return ( +

+ {formatCellValue(value)} +

+ ); + }, + })); + }, [selectedExecution]); + + return ( +
+ +
+ {!selectedExecution ? ( +
+ Select an execution. +
+ ) : ( +
+
+
+

+ {selectedExecution.kind} execution +

+ + {selectedExecution.status} + + {isStale && ( + Recipe changed since this run + )} +
+

+ Started {formatTimestamp(selectedExecution.createdAt)} |{" "} + {selectedExecution.rows} rows +

+
+ + {selectedExecution.status === "running" && ( +
+ + +
+ )} + + {selectedExecution.status === "error" && ( +
+

Preview failed

+

+ {selectedExecution.error ?? "Unknown error."} +

+
+ )} + + {selectedExecution.status === "completed" && ( + <> +
+

Analysis (full)

+
+                    {JSON.stringify(selectedExecution.analysis ?? {}, null, 2)}
+                  
+
+
+

Preview data

+ {selectedExecution.dataset.length === 0 ? ( +

No rows returned.

+ ) : ( +
+ +
+ )} +
+ + )} +
+ )} +
+
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx index 4229236c2d..b5ff7ec82b 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx @@ -8,16 +8,20 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { RecipeStudioView } from "../execution-types"; type StatusTone = "success" | "error"; type RecipeStudioHeaderProps = { + activeView: RecipeStudioView; previewLoading: boolean; saveLoading: boolean; saveTone: StatusTone; savedAtLabel: string; workflowName: string; onWorkflowNameChange: (value: string) => void; + onViewChange: (view: RecipeStudioView) => void; onPreview: () => void; onSaveRecipe: () => void; }; @@ -28,17 +32,25 @@ const STATUS_MESSAGE_CLASS: Record = { }; export function RecipeStudioHeader({ + activeView, previewLoading, saveLoading, saveTone, savedAtLabel, workflowName, onWorkflowNameChange, + onViewChange, onPreview, onSaveRecipe, }: RecipeStudioHeaderProps): ReactElement { const [editingWorkflowName, setEditingWorkflowName] = useState(false); + function handleViewValueChange(value: string): void { + if (value === "editor" || value === "executions") { + onViewChange(value); + } + } + function closeWorkflowNameEditor(): void { if (workflowName.trim().length === 0) { onWorkflowNameChange("Unnamed"); @@ -57,7 +69,7 @@ export function RecipeStudioHeader({ } return ( -
+
-
+
+ + + Editor + Executions + + +
+