From 7d35463abc38e312147a85abf070d742af8666e4 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Tue, 3 Mar 2026 10:34:32 +0100 Subject: [PATCH] feat(recipe-studio): add execution progress island and collapsible advanced options for validators --- .../runtime/execution-progress-island.tsx | 146 ++++++++++++++++++ .../recipe-studio/dialogs/config-dialog.tsx | 2 +- .../dialogs/validators/validator-dialog.tsx | 55 ++++--- .../recipe-studio/recipe-studio-page.tsx | 88 ++++++++--- 4 files changed, 243 insertions(+), 48 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx diff --git a/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx new file mode 100644 index 0000000000..9cf2791416 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx @@ -0,0 +1,146 @@ +import { + ArrowDown01Icon, + ArrowUp01Icon, + CheckmarkCircle02Icon, + Flag02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import type { RecipeExecutionRecord } from "../../execution-types"; +import { isExecutionInProgress } from "../../executions/execution-helpers"; +import { + formatMetricValue, + formatPercent, +} from "../executions/executions-view-helpers"; + +type ExecutionProgressIslandProps = { + execution: RecipeExecutionRecord; + currentColumnIcon: typeof Flag02Icon; + minimized: boolean; + onMinimizedChange: (value: boolean) => void; + onViewExecutions: () => void; +}; + +function formatEta(value: number | null | undefined): string { + const metric = formatMetricValue(value); + if (metric === "--") { + return "--"; + } + return `${metric}s`; +} + +function statusLabel(input: { + complete: boolean; + inProgress: boolean; +}): string { + if (input.complete) { + return "Run completed"; + } + if (input.inProgress) { + return "Run in progress"; + } + return "Run status"; +} + +export function ExecutionProgressIsland({ + execution, + currentColumnIcon, + minimized, + onMinimizedChange, + onViewExecutions, +}: ExecutionProgressIslandProps): ReactElement { + const complete = execution.status === "completed"; + const inProgress = isExecutionInProgress(execution.status); + const progressPercent = execution.progress?.percent ?? (complete ? 100 : 0); + const hasProgressSignal = Boolean( + execution.progress && + (typeof execution.progress.done === "number" || + typeof execution.progress.total === "number" || + typeof execution.progress.percent === "number" || + typeof execution.progress.rate === "number" || + typeof execution.progress.eta_sec === "number"), + ); + const showLoadingSpinner = inProgress && !hasProgressSignal; + const batchTotal = execution.batch?.total ?? null; + const showBatch = typeof batchTotal === "number" && batchTotal > 1; + + return ( +
+
+
+ +

+ {statusLabel({ complete, inProgress })} +

+
+
+ {showLoadingSpinner && ( + + )} + {formatPercent(progressPercent)} + +
+
+ +
+ +
+ + {!minimized && ( + <> +
+

Done: {formatMetricValue(execution.progress?.done)}

+

Total: {formatMetricValue(execution.progress?.total)}

+

Rate: {formatMetricValue(execution.progress?.rate)}

+

ETA: {formatEta(execution.progress?.eta_sec)}

+
+
+ +

Column: {execution.current_column ?? "--"}

+
+ {showBatch && ( +
+ Batch: {execution.batch?.idx ?? "--"}/{execution.batch?.total ?? "--"} +
+ )} +
+ +
+ + )} +
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx index 34b8ea95c3..10c7c6b073 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx @@ -74,7 +74,7 @@ export function ConfigDialog({
{showDropToggle && ( -
+

Drop from final dataset

diff --git a/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx index 57c9b0772a..be87c7b912 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx @@ -1,3 +1,8 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Select, @@ -6,7 +11,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { type ReactElement, useMemo } from "react"; +import { type ReactElement, useMemo, useState } from "react"; import { useRecipeStudioStore } from "../../stores/recipe-studio"; import type { ValidatorConfig } from "../../types"; import { isValidatorCodeLang } from "../../utils/validators/code-lang"; @@ -27,6 +32,7 @@ export function ValidatorDialog({ const configs = useRecipeStudioStore((state) => state.configs); const targetColumnId = `${config.id}-target-column`; const batchSizeId = `${config.id}-batch-size`; + const [advancedOpen, setAdvancedOpen] = useState(false); const codeOptions = useMemo( () => Object.values(configs) @@ -46,7 +52,6 @@ export function ValidatorDialog({ [configs], ); const currentTarget = config.target_columns[0] ?? ""; - const engineLabel = config.code_lang.startsWith("sql:") ? "SQL" : "Python"; return (

@@ -54,13 +59,6 @@ export function ValidatorDialog({ value={config.name} onChange={(value) => onUpdate({ name: value })} /> -
- - -
)}
-
- - onUpdate({ batch_size: event.target.value })} - /> -
+ + + + + +
+ + onUpdate({ batch_size: event.target.value })} + /> +
+
+
); } diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index cadb472da7..dc5582b656 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -30,6 +30,7 @@ import { RunValidateFloatingControls } from "./components/controls/run-validate- import { ViewportControls } from "./components/controls/viewport-controls"; import { ExecutionsView } from "./components/executions/executions-view"; import { InternalsSync } from "./components/graph/internals-sync"; +import { ExecutionProgressIsland } from "./components/runtime/execution-progress-island"; import { RecipeStudioHeader } from "./components/recipe-studio-header"; import { RecipeNode } from "./components/recipe-graph-node"; import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge"; @@ -42,16 +43,18 @@ import { useRecipeEditorGraph } from "./hooks/use-recipe-editor-graph"; import { useRecipeRuntimeVisuals } from "./hooks/use-recipe-runtime-visuals"; import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions"; import { useRecipeStudioStore } from "./stores/recipe-studio"; +import { isExecutionInProgress } from "./executions/execution-helpers"; import type { RecipeNodeData } from "./types"; import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view"; import { buildRecipePayload } from "./utils/payload"; import type { RecipePayload } from "./utils/payload/types"; import { buildDefaultSchemaTransform } from "./utils/processors"; import { buildDialogOptions } from "./utils/recipe-studio-view"; -import type { RecipeStudioView } from "./execution-types"; +import type { RecipeExecutionRecord, RecipeStudioView } from "./execution-types"; const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode }; const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge }; +const COMPLETE_ISLAND_VISIBLE_MS = 7_000; export type PersistRecipeInput = { id: string | null; @@ -162,19 +165,17 @@ export function RecipeStudioPage({ const [activeView, setActiveView] = useState("editor"); const [processorsOpen, setProcessorsOpen] = useState(false); const [interactive, setInteractive] = useState(true); + const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false); + const [recentCompletedExecution, setRecentCompletedExecution] = + useState(null); const [reactFlowInstance, setReactFlowInstance] = useState< ReactFlowInstance, Edge> | null >(null); const lastProcessedFitTickRef = useRef(0); const previousActiveViewRef = useRef("editor"); + const previousActiveExecutionIdRef = useRef(null); const pendingEditorTabFitRef = useRef(false); const viewportMovedSinceAutoFitRef = useRef(true); - const handleExecutionStart = useCallback(() => { - setActiveView("executions"); - }, []); - const handlePreviewSuccess = useCallback(() => { - setActiveView("executions"); - }, []); const { handleNodeClick, handleNodeDoubleClick, @@ -291,8 +292,6 @@ export function RecipeStudioPage({ resetRecipe, loadRecipe, getCurrentPayloadFromStore, - onExecutionStart: handleExecutionStart, - onPreviewSuccess: handlePreviewSuccess, }); const { activeExecution, @@ -312,6 +311,7 @@ export function RecipeStudioPage({ const executionLocked = runtimeVisualState.executionLocked; const canvasInteractive = interactive && !executionLocked; const runBusy = previewLoading || fullLoading || executionLocked; + const islandExecution = activeExecution ?? recentCompletedExecution; const toggleInteractive = useCallback(() => { if (executionLocked) { @@ -324,6 +324,47 @@ export function RecipeStudioPage({ setExecutionLocked(executionLocked); }, [executionLocked, setExecutionLocked]); + useEffect(() => { + const activeExecutionId = activeExecution?.id ?? null; + if ( + activeExecutionId && + activeExecutionId !== previousActiveExecutionIdRef.current + ) { + setRuntimeIslandMinimized(false); + } + previousActiveExecutionIdRef.current = activeExecutionId; + }, [activeExecution?.id]); + + useEffect(() => { + if (activeExecution) { + setRecentCompletedExecution(null); + return; + } + const latestCompleted = executions.find( + (execution) => + execution.status === "completed" && typeof execution.finishedAt === "number", + ); + if (!latestCompleted || typeof latestCompleted.finishedAt !== "number") { + setRecentCompletedExecution(null); + return; + } + const elapsedMs = Date.now() - latestCompleted.finishedAt; + if (elapsedMs >= COMPLETE_ISLAND_VISIBLE_MS) { + setRecentCompletedExecution(null); + return; + } + setRecentCompletedExecution(latestCompleted); + const hideTimer = window.setTimeout(() => { + setRecentCompletedExecution(null); + setActiveView((currentView) => + currentView === "editor" ? "executions" : currentView, + ); + }, COMPLETE_ISLAND_VISIBLE_MS - elapsedMs); + return () => { + window.clearTimeout(hideTimer); + }; + }, [activeExecution, executions]); + const openProcessorsFromSheet = useCallback(() => { if ( !processors.some( @@ -538,22 +579,19 @@ export function RecipeStudioPage({ lockDisabled={executionLocked} onToggleInteractive={toggleInteractive} /> - {runtimeVisualState.batch && ( - -
-

- Batch {runtimeVisualState.batch.idx ?? "--"}/ - {runtimeVisualState.batch.total} -

- {activeExecution?.current_column && ( -
- -

Column: {activeExecution.current_column}

-
- )} -
-
- )} + {islandExecution && + (isExecutionInProgress(islandExecution.status) || + islandExecution.status === "completed") && ( + + setActiveView("executions")} + /> + + )}