feat(recipe-studio): add execution progress island and collapsible advanced options for validators
This commit is contained in:
parent
761c84f92b
commit
7d35463abc
4 changed files with 243 additions and 48 deletions
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[clamp(15rem,26vw,20rem)] rounded-b-xl border-x border-b bg-card/96 shadow-sm backdrop-blur-sm transition-all",
|
||||
minimized ? "min-h-[3rem]" : "min-h-[8.5rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={complete ? CheckmarkCircle02Icon : Flag02Icon}
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
complete
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
/>
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{statusLabel({ complete, inProgress })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showLoadingSpinner && (
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">{formatPercent(progressPercent)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMinimizedChange(!minimized)}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded border border-border/70 text-muted-foreground transition hover:bg-muted/50"
|
||||
aria-label={minimized ? "Expand progress" : "Minimize progress"}
|
||||
title={minimized ? "Expand" : "Minimize"}
|
||||
>
|
||||
<HugeiconsIcon icon={minimized ? ArrowDown01Icon : ArrowUp01Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<Progress value={progressPercent} className="h-1" />
|
||||
</div>
|
||||
|
||||
{!minimized && (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<p>Done: {formatMetricValue(execution.progress?.done)}</p>
|
||||
<p>Total: {formatMetricValue(execution.progress?.total)}</p>
|
||||
<p>Rate: {formatMetricValue(execution.progress?.rate)}</p>
|
||||
<p>ETA: {formatEta(execution.progress?.eta_sec)}</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 px-3 text-[11px] text-muted-foreground">
|
||||
<HugeiconsIcon icon={currentColumnIcon} className="size-3.5" />
|
||||
<p className="truncate">Column: {execution.current_column ?? "--"}</p>
|
||||
</div>
|
||||
{showBatch && (
|
||||
<div className="mt-1 px-3 text-[11px] text-muted-foreground">
|
||||
Batch: {execution.batch?.idx ?? "--"}/{execution.batch?.total ?? "--"}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[11px]"
|
||||
onClick={onViewExecutions}
|
||||
>
|
||||
View more in executions view
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ export function ConfigDialog({
|
|||
<ValidationBanner config={config} />
|
||||
<div className={readOnly ? "pointer-events-none opacity-75" : undefined}>
|
||||
{showDropToggle && (
|
||||
<div className="flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
|
||||
<div className="mb-2 flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 pt-2 pb-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Drop from final dataset</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -54,13 +59,6 @@ export function ValidatorDialog({
|
|||
value={config.name}
|
||||
onChange={(value) => onUpdate({ name: value })}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Validator engine"
|
||||
hint="Built-in validator type for this block."
|
||||
/>
|
||||
<Input value={engineLabel} disabled={true} className="nodrag" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Target code column"
|
||||
|
|
@ -108,19 +106,32 @@ export function ValidatorDialog({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Batch size"
|
||||
htmlFor={batchSizeId}
|
||||
hint="Records per validation batch."
|
||||
/>
|
||||
<Input
|
||||
id={batchSizeId}
|
||||
className="nodrag"
|
||||
value={config.batch_size}
|
||||
onChange={(event) => onUpdate({ batch_size: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="font-semibold uppercase">Advanced</span>
|
||||
<span>{advancedOpen ? "Hide" : "Show"}</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Batch size"
|
||||
htmlFor={batchSizeId}
|
||||
hint="Records per validation batch."
|
||||
/>
|
||||
<Input
|
||||
id={batchSizeId}
|
||||
className="nodrag"
|
||||
value={config.batch_size}
|
||||
onChange={(event) => onUpdate({ batch_size: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecipeStudioView>("editor");
|
||||
const [processorsOpen, setProcessorsOpen] = useState(false);
|
||||
const [interactive, setInteractive] = useState(true);
|
||||
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
|
||||
const [recentCompletedExecution, setRecentCompletedExecution] =
|
||||
useState<RecipeExecutionRecord | null>(null);
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<
|
||||
ReactFlowInstance<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge> | null
|
||||
>(null);
|
||||
const lastProcessedFitTickRef = useRef(0);
|
||||
const previousActiveViewRef = useRef<RecipeStudioView>("editor");
|
||||
const previousActiveExecutionIdRef = useRef<string | null>(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 && (
|
||||
<Panel position="top-center" className="m-3">
|
||||
<div className="rounded-lg border border-border/70 bg-card/95 px-3 py-2 text-xs shadow-sm">
|
||||
<p className="font-medium text-foreground">
|
||||
Batch {runtimeVisualState.batch.idx ?? "--"}/
|
||||
{runtimeVisualState.batch.total}
|
||||
</p>
|
||||
{activeExecution?.current_column && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<HugeiconsIcon icon={currentColumnIcon} className="size-3.5" />
|
||||
<p>Column: {activeExecution.current_column}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{islandExecution &&
|
||||
(isExecutionInProgress(islandExecution.status) ||
|
||||
islandExecution.status === "completed") && (
|
||||
<Panel position="top-center" className="!m-0">
|
||||
<ExecutionProgressIsland
|
||||
execution={islandExecution}
|
||||
currentColumnIcon={currentColumnIcon}
|
||||
minimized={runtimeIslandMinimized}
|
||||
onMinimizedChange={setRuntimeIslandMinimized}
|
||||
onViewExecutions={() => setActiveView("executions")}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
<RunValidateFloatingControls
|
||||
runBusy={runBusy}
|
||||
runDialogKind={runDialogKind}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue