feat: introduce execution tracking and analysis for recipe preview
- Added `ExecutionsView` with execution history tracking, live updates, and detailed data analysis. - Implemented IndexedDB support via Dexie to persist execution records locally. - Enhanced backend preview logic to return execution analysis and artifacts. - Updated studio header with view toggling between "Editor" and "Executions."
This commit is contained in:
parent
763001b78e
commit
d378e48c2a
10 changed files with 503 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
analysis?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ValidateError = {
|
||||
|
|
|
|||
|
|
@ -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<ColumnDef<Record<string, unknown>>[]>(() => {
|
||||
if (!selectedExecution) {
|
||||
return [];
|
||||
}
|
||||
const names = new Set<string>();
|
||||
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 (
|
||||
<p className="max-w-[32rem] whitespace-pre-wrap break-all">
|
||||
{formatCellValue(value)}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
}));
|
||||
}, [selectedExecution]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="w-72 shrink-0 border-r">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Executions
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRunPreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? "Running..." : "Run preview"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-45px)] overflow-auto p-2">
|
||||
{executions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-3 text-xs text-muted-foreground">
|
||||
No executions yet.
|
||||
</div>
|
||||
) : (
|
||||
executions.map((execution) => (
|
||||
<button
|
||||
key={execution.id}
|
||||
type="button"
|
||||
onClick={() => onSelectExecution(execution.id)}
|
||||
className={cn(
|
||||
"mb-2 w-full rounded-xl corner-squircle border p-3 text-left",
|
||||
selectedExecutionId === execution.id
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium capitalize">
|
||||
{execution.kind}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("capitalize", statusTone(execution.status))}
|
||||
>
|
||||
{execution.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{execution.rows} rows
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(execution.createdAt)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="min-w-0 flex-1 overflow-auto p-4">
|
||||
{!selectedExecution ? (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Select an execution.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold capitalize">
|
||||
{selectedExecution.kind} execution
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("capitalize", statusTone(selectedExecution.status))}
|
||||
>
|
||||
{selectedExecution.status}
|
||||
</Badge>
|
||||
{isStale && (
|
||||
<Badge variant="outline">Recipe changed since this run</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Started {formatTimestamp(selectedExecution.createdAt)} |{" "}
|
||||
{selectedExecution.rows} rows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedExecution.status === "running" && (
|
||||
<div className="space-y-2 rounded-xl border p-3">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExecution.status === "error" && (
|
||||
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-3">
|
||||
<p className="text-sm font-semibold text-destructive">Preview failed</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{selectedExecution.error ?? "Unknown error."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExecution.status === "completed" && (
|
||||
<>
|
||||
<div className="rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Analysis (full)</p>
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||
{JSON.stringify(selectedExecution.analysis ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Preview data</p>
|
||||
{selectedExecution.dataset.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No rows returned.</p>
|
||||
) : (
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={selectedExecution.dataset}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<StatusTone, string> = {
|
|||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between gap-4 border-b px-4 py-3">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -91,7 +103,15 @@ export function RecipeStudioHeader({
|
|||
<span className="text-xs text-muted-foreground">{savedAtLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="justify-self-center">
|
||||
<Tabs value={activeView} onValueChange={handleViewValueChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="editor">Editor</TabsTrigger>
|
||||
<TabsTrigger value="executions">Executions</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex items-center justify-self-end gap-2">
|
||||
<Button type="button" size="sm" onClick={onPreview} disabled={previewLoading}>
|
||||
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
|
||||
{previewLoading ? "Previewing..." : "Preview"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import Dexie, { type EntityTable } from "dexie";
|
||||
import type { RecipeExecutionRecord } from "../execution-types";
|
||||
|
||||
const db = new Dexie("unsloth-data-recipe-executions") as Dexie & {
|
||||
executions: EntityTable<RecipeExecutionRecord, "id">;
|
||||
};
|
||||
|
||||
db.version(1).stores({
|
||||
executions: "id, recipeId, kind, status, createdAt",
|
||||
});
|
||||
|
||||
export async function listRecipeExecutions(
|
||||
recipeId: string,
|
||||
): Promise<RecipeExecutionRecord[]> {
|
||||
const executions = await db.executions.where("recipeId").equals(recipeId).toArray();
|
||||
return executions.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export async function saveRecipeExecution(
|
||||
execution: RecipeExecutionRecord,
|
||||
): Promise<void> {
|
||||
await db.executions.put(execution);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
export type RecipeStudioView = "editor" | "executions";
|
||||
|
||||
export type RecipeExecutionKind = "preview" | "full";
|
||||
|
||||
export type RecipeExecutionStatus = "running" | "completed" | "error";
|
||||
|
||||
export type RecipeExecutionRecord = {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
kind: RecipeExecutionKind;
|
||||
status: RecipeExecutionStatus;
|
||||
rows: number;
|
||||
createdAt: number;
|
||||
recipeSignature: string;
|
||||
dataset: Record<string, unknown>[];
|
||||
analysis: Record<string, unknown> | null;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
processor_artifacts: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import { toastError, toastSuccess } from "@/shared/toast";
|
||||
import { normalizeNonEmptyName } from "@/utils";
|
||||
import { previewRecipe, validateRecipe } from "../api";
|
||||
import { listRecipeExecutions, saveRecipeExecution } from "../data/executions-db";
|
||||
import type { RecipeExecutionRecord } from "../execution-types";
|
||||
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
|
||||
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ type UseRecipeStudioActionsParams = {
|
|||
resetRecipe: () => void;
|
||||
loadRecipe: (snapshot: RecipeSnapshot) => void;
|
||||
getCurrentPayloadFromStore: () => RecipePayload;
|
||||
onPreviewSuccess?: () => void;
|
||||
};
|
||||
|
||||
type UseRecipeStudioActionsResult = {
|
||||
|
|
@ -43,9 +46,13 @@ type UseRecipeStudioActionsResult = {
|
|||
setPreviewRows: (rows: number) => void;
|
||||
previewErrors: string[];
|
||||
previewLoading: boolean;
|
||||
currentSignature: string;
|
||||
executions: RecipeExecutionRecord[];
|
||||
selectedExecutionId: string | null;
|
||||
setSelectedExecutionId: (id: string) => void;
|
||||
persistRecipe: () => Promise<void>;
|
||||
openPreviewDialog: () => void;
|
||||
runPreview: () => Promise<void>;
|
||||
runPreview: () => Promise<boolean>;
|
||||
copyRecipe: () => Promise<void>;
|
||||
importRecipe: (value: string) => string | null;
|
||||
};
|
||||
|
|
@ -72,6 +79,23 @@ function toErrorMessage(error: unknown, fallback: string): string {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeDatasetRows(value: unknown): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
typeof row === "object" && row !== null && !Array.isArray(row),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeObject(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
|
|
@ -109,6 +133,7 @@ export function useRecipeStudioActions({
|
|||
resetRecipe,
|
||||
loadRecipe,
|
||||
getCurrentPayloadFromStore,
|
||||
onPreviewSuccess,
|
||||
}: UseRecipeStudioActionsParams): UseRecipeStudioActionsResult {
|
||||
const [workflowName, setWorkflowName] = useState("Unnamed");
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
|
|
@ -120,6 +145,10 @@ export function useRecipeStudioActions({
|
|||
const [previewRows, setPreviewRows] = useState(5);
|
||||
const [previewErrors, setPreviewErrors] = useState<string[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [executions, setExecutions] = useState<RecipeExecutionRecord[]>([]);
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const normalizedWorkflowName = useMemo(
|
||||
() => normalizeNonEmptyName(workflowName, "Unnamed"),
|
||||
|
|
@ -143,6 +172,7 @@ export function useRecipeStudioActions({
|
|||
setCopied(false);
|
||||
setPreviewErrors([]);
|
||||
setPreviewDialogOpen(false);
|
||||
setSelectedExecutionId(null);
|
||||
|
||||
const parsed = importRecipePayload(JSON.stringify(initialPayload));
|
||||
if (parsed.snapshot) {
|
||||
|
|
@ -163,6 +193,43 @@ export function useRecipeStudioActions({
|
|||
resetRecipe,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setExecutions([]);
|
||||
setSelectedExecutionId(null);
|
||||
|
||||
async function loadExecutions(): Promise<void> {
|
||||
try {
|
||||
const records = await listRecipeExecutions(recipeId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setExecutions(records);
|
||||
setSelectedExecutionId(records[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
console.error("Load recipe executions failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadExecutions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [recipeId]);
|
||||
|
||||
const upsertExecution = useCallback((record: RecipeExecutionRecord): void => {
|
||||
setExecutions((current) => {
|
||||
const next = current.filter((item) => item.id !== record.id);
|
||||
next.unshift(record);
|
||||
return next;
|
||||
});
|
||||
setSelectedExecutionId(record.id);
|
||||
void saveRecipeExecution(record).catch((error) => {
|
||||
console.error("Save recipe execution failed:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistRecipe = useCallback(async (): Promise<void> => {
|
||||
if (saveLoading) {
|
||||
return;
|
||||
|
|
@ -210,15 +277,30 @@ export function useRecipeStudioActions({
|
|||
setPreviewDialogOpen(true);
|
||||
}
|
||||
|
||||
const runPreview = useCallback(async (): Promise<void> => {
|
||||
setPreviewLoading(true);
|
||||
const runPreview = useCallback(async (): Promise<boolean> => {
|
||||
const payload = readPayload();
|
||||
if (!payload) {
|
||||
setPreviewErrors(payloadResult.errors);
|
||||
toastError("Invalid recipe payload", payloadErrorMessage);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
|
||||
const createdAt = Date.now();
|
||||
const baseExecution: RecipeExecutionRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
kind: "preview",
|
||||
status: "running",
|
||||
rows: previewRows,
|
||||
createdAt,
|
||||
recipeSignature: currentSignature,
|
||||
dataset: [],
|
||||
analysis: null,
|
||||
processor_artifacts: null,
|
||||
error: null,
|
||||
};
|
||||
upsertExecution(baseExecution);
|
||||
|
||||
const previewPayload = {
|
||||
...payload,
|
||||
|
|
@ -234,24 +316,58 @@ export function useRecipeStudioActions({
|
|||
const errors = validation.errors.map((item) => item.message);
|
||||
const fallback = validation.raw_detail ?? "Validation failed.";
|
||||
const nextErrors = errors.length > 0 ? errors : [fallback];
|
||||
upsertExecution({
|
||||
...baseExecution,
|
||||
status: "error",
|
||||
error: nextErrors[0],
|
||||
});
|
||||
setPreviewErrors(nextErrors);
|
||||
toastError("Validation failed", nextErrors[0]);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
await previewRecipe(previewPayload);
|
||||
const result = await previewRecipe(previewPayload);
|
||||
upsertExecution({
|
||||
...baseExecution,
|
||||
status: "completed",
|
||||
dataset: normalizeDatasetRows(result.dataset),
|
||||
analysis: normalizeObject(result.analysis),
|
||||
processor_artifacts: normalizeObject(result.processor_artifacts),
|
||||
error: null,
|
||||
});
|
||||
setPreviewDialogOpen(false);
|
||||
setPreviewErrors([]);
|
||||
toastSuccess(`Preview generated (${previewRows} rows).`);
|
||||
onPreviewSuccess?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Preview failed:", error);
|
||||
const message = toErrorMessage(error, "Preview request failed.");
|
||||
upsertExecution({
|
||||
...baseExecution,
|
||||
status: "error",
|
||||
error: message,
|
||||
});
|
||||
setPreviewErrors([message]);
|
||||
toastError("Preview failed", message);
|
||||
return false;
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [payloadErrorMessage, payloadResult.errors, previewRows, readPayload]);
|
||||
}, [
|
||||
currentSignature,
|
||||
onPreviewSuccess,
|
||||
payloadErrorMessage,
|
||||
payloadResult.errors,
|
||||
previewRows,
|
||||
readPayload,
|
||||
recipeId,
|
||||
upsertExecution,
|
||||
]);
|
||||
|
||||
const selectExecution = useCallback((id: string): void => {
|
||||
setSelectedExecutionId(id);
|
||||
}, []);
|
||||
|
||||
const copyRecipe = useCallback(async (): Promise<void> => {
|
||||
setCopied(false);
|
||||
|
|
@ -302,6 +418,10 @@ export function useRecipeStudioActions({
|
|||
setPreviewRows,
|
||||
previewErrors,
|
||||
previewLoading,
|
||||
currentSignature,
|
||||
executions,
|
||||
selectedExecutionId,
|
||||
setSelectedExecutionId: selectExecution,
|
||||
persistRecipe,
|
||||
openPreviewDialog,
|
||||
runPreview,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/re
|
|||
import { BlockSheet } from "./components/block-sheet";
|
||||
import { LayoutControls } from "./components/controls/layout-controls";
|
||||
import { ViewportControls } from "./components/controls/viewport-controls";
|
||||
import { ExecutionsView } from "./components/executions/executions-view";
|
||||
import { InternalsSync } from "./components/graph/internals-sync";
|
||||
import { RecipeStudioHeader } from "./components/recipe-studio-header";
|
||||
import { RecipeNode } from "./components/recipe-graph-node";
|
||||
|
|
@ -51,6 +52,7 @@ import {
|
|||
buildDialogOptions,
|
||||
buildPreviewSummary,
|
||||
} from "./utils/recipe-studio-view";
|
||||
import type { RecipeStudioView } from "./execution-types";
|
||||
|
||||
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
|
||||
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
|
||||
|
|
@ -155,6 +157,7 @@ export function RecipeStudioPage({
|
|||
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
|
||||
const [processorsOpen, setProcessorsOpen] = useState(false);
|
||||
const [interactive, setInteractive] = useState(true);
|
||||
|
||||
|
|
@ -269,6 +272,10 @@ export function RecipeStudioPage({
|
|||
setPreviewRows,
|
||||
previewErrors,
|
||||
previewLoading,
|
||||
currentSignature,
|
||||
executions,
|
||||
selectedExecutionId,
|
||||
setSelectedExecutionId,
|
||||
persistRecipe,
|
||||
openPreviewDialog,
|
||||
runPreview,
|
||||
|
|
@ -284,6 +291,9 @@ export function RecipeStudioPage({
|
|||
resetRecipe,
|
||||
loadRecipe,
|
||||
getCurrentPayloadFromStore,
|
||||
onPreviewSuccess: () => {
|
||||
setActiveView("executions");
|
||||
},
|
||||
});
|
||||
|
||||
const openProcessorsFromSheet = useCallback(() => {
|
||||
|
|
@ -305,72 +315,85 @@ export function RecipeStudioPage({
|
|||
ref={setSheetContainer}
|
||||
>
|
||||
<RecipeStudioHeader
|
||||
activeView={activeView}
|
||||
previewLoading={previewLoading}
|
||||
saveLoading={saveLoading}
|
||||
saveTone={saveTone}
|
||||
savedAtLabel={savedAtLabel}
|
||||
workflowName={workflowName}
|
||||
onWorkflowNameChange={setWorkflowName}
|
||||
onViewChange={setActiveView}
|
||||
onPreview={openPreviewDialog}
|
||||
onSaveRecipe={() => {
|
||||
void persistRecipe();
|
||||
}}
|
||||
/>
|
||||
<div className="h-[75vh] w-full rounded-t-none">
|
||||
<ReactFlow
|
||||
nodes={displayGraph.nodes}
|
||||
edges={displayGraph.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{
|
||||
type: "canvas",
|
||||
data: { path: "auto" },
|
||||
}}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
isValidConnection={isValidConnection}
|
||||
nodesDraggable={interactive}
|
||||
nodesConnectable={interactive}
|
||||
elementsSelectable={interactive}
|
||||
fitView={true}
|
||||
className="h-full w-full rounded-t-none"
|
||||
>
|
||||
<LayoutControls
|
||||
direction={layoutDirection}
|
||||
onLayout={applyLayout}
|
||||
onToggleDirection={handleToggleDirection}
|
||||
/>
|
||||
<InternalsSync nodeIds={displayNodeIds} />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={18}
|
||||
size={1}
|
||||
color="#d4d4d8"
|
||||
/>
|
||||
<Panel position="top-right" className="m-3">
|
||||
<BlockSheet
|
||||
container={sheetContainer}
|
||||
sheetView={sheetView}
|
||||
onViewChange={setSheetView}
|
||||
onAddSampler={addSamplerNode}
|
||||
onAddSeed={addSeedNode}
|
||||
onAddLlm={addLlmNode}
|
||||
onAddModelProvider={addModelProviderNode}
|
||||
onAddModelConfig={addModelConfigNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
copied={copied}
|
||||
onCopy={copyRecipe}
|
||||
onImport={() => setImportOpen(true)}
|
||||
{activeView === "editor" ? (
|
||||
<ReactFlow
|
||||
nodes={displayGraph.nodes}
|
||||
edges={displayGraph.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{
|
||||
type: "canvas",
|
||||
data: { path: "auto" },
|
||||
}}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
isValidConnection={isValidConnection}
|
||||
nodesDraggable={interactive}
|
||||
nodesConnectable={interactive}
|
||||
elementsSelectable={interactive}
|
||||
fitView={true}
|
||||
className="h-full w-full rounded-t-none"
|
||||
>
|
||||
<LayoutControls
|
||||
direction={layoutDirection}
|
||||
onLayout={applyLayout}
|
||||
onToggleDirection={handleToggleDirection}
|
||||
/>
|
||||
</Panel>
|
||||
<ViewportControls
|
||||
interactive={interactive}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
<InternalsSync nodeIds={displayNodeIds} />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={18}
|
||||
size={1}
|
||||
color="#d4d4d8"
|
||||
/>
|
||||
<Panel position="top-right" className="m-3">
|
||||
<BlockSheet
|
||||
container={sheetContainer}
|
||||
sheetView={sheetView}
|
||||
onViewChange={setSheetView}
|
||||
onAddSampler={addSamplerNode}
|
||||
onAddSeed={addSeedNode}
|
||||
onAddLlm={addLlmNode}
|
||||
onAddModelProvider={addModelProviderNode}
|
||||
onAddModelConfig={addModelConfigNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
copied={copied}
|
||||
onCopy={copyRecipe}
|
||||
onImport={() => setImportOpen(true)}
|
||||
/>
|
||||
</Panel>
|
||||
<ViewportControls
|
||||
interactive={interactive}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
/>
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<ExecutionsView
|
||||
executions={executions}
|
||||
selectedExecutionId={selectedExecutionId}
|
||||
currentSignature={currentSignature}
|
||||
previewLoading={previewLoading}
|
||||
onSelectExecution={setSelectedExecutionId}
|
||||
onRunPreview={openPreviewDialog}
|
||||
/>
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue