diff --git a/studio/backend/core/data_recipe/jobs/parse.py b/studio/backend/core/data_recipe/jobs/parse.py index 9096216676..99d1a85a79 100644 --- a/studio/backend/core/data_recipe/jobs/parse.py +++ b/studio/backend/core/data_recipe/jobs/parse.py @@ -103,7 +103,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None: return ParsedUpdate(usage_section_start=True) m = _RE_USAGE_MODEL.search(msg) - if m and " |-- model:" in msg: + if m and "|-- model:" in msg: return ParsedUpdate(usage_model=str(m.group("model")).strip()) m = _RE_USAGE_TOKENS.search(msg) 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 index 00abb420a0..b2976c95ab 100644 --- a/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx +++ b/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx @@ -1,5 +1,12 @@ -import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; import type { ColumnDef } from "@tanstack/react-table"; +import { + CheckmarkCircle02Icon, + Database01Icon, + Database02Icon, + Flag02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DataTable } from "@/components/ui/data-table"; @@ -11,7 +18,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; -import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, @@ -47,6 +53,20 @@ type AnalysisColumnStat = { simple_dtype: string; num_unique: number | null; num_null: number | null; + input_tokens_mean: number | null; + output_tokens_mean: number | null; +}; + +type ModelUsageRow = { + model: string; + input: number | null; + output: number | null; + total: number | null; + tps: number | null; + requestsSuccess: number | null; + requestsFailed: number | null; + requestsTotal: number | null; + rpm: number | null; }; function formatTimestamp(value: number): string { @@ -105,6 +125,8 @@ function parseAnalysisColumns(analysis: RecipeExecutionAnalysis | null): Analysi simple_dtype: parseString(row.simple_dtype), num_unique: parseNumber(row.num_unique), num_null: parseNumber(row.num_null), + input_tokens_mean: parseNumber(row.input_tokens_mean), + output_tokens_mean: parseNumber(row.output_tokens_mean), }; }) .filter((item): item is AnalysisColumnStat => item !== null); @@ -167,6 +189,52 @@ function formatDuration(startedAt: number, finishedAt: number | null): string { return `${seconds}s`; } +function formatMetricValue(value: number | null | undefined): string { + if (typeof value !== "number" || Number.isNaN(value)) { + return "--"; + } + return value.toLocaleString(); +} + +function parseModelUsageRows(value: Record | null): ModelUsageRow[] { + if (!value) { + return []; + } + return Object.entries(value) + .map(([name, data]) => { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return null; + } + const modelObj = data as Record; + const tokens = + modelObj.tokens && typeof modelObj.tokens === "object" && !Array.isArray(modelObj.tokens) + ? (modelObj.tokens as Record) + : null; + const requests = + modelObj.requests && + typeof modelObj.requests === "object" && + !Array.isArray(modelObj.requests) + ? (modelObj.requests as Record) + : null; + const modelName = + typeof modelObj.model === "string" && modelObj.model.length > 0 + ? modelObj.model + : name; + return { + model: modelName, + input: parseNumber(tokens?.input), + output: parseNumber(tokens?.output), + total: parseNumber(tokens?.total), + tps: parseNumber(tokens?.tps), + requestsSuccess: parseNumber(requests?.success), + requestsFailed: parseNumber(requests?.failed), + requestsTotal: parseNumber(requests?.total), + rpm: parseNumber(requests?.rpm), + }; + }) + .filter((item): item is ModelUsageRow => item !== null); +} + export function ExecutionsView({ executions, selectedExecutionId, @@ -185,6 +253,7 @@ export function ExecutionsView({ const [expandedDatasetCells, setExpandedDatasetCells] = useState< Record >({}); + const terminalRef = useRef(null); const selectedExecution = useMemo( () => executions.find((execution) => execution.id === selectedExecutionId) ?? @@ -277,13 +346,10 @@ export function ExecutionsView({ () => parseAnalysisColumns(selectedExecution?.analysis ?? null), [selectedExecution?.analysis], ); - const columnTypeCounts = useMemo(() => { - const map = new Map(); - for (const column of analysisColumns) { - map.set(column.column_type, (map.get(column.column_type) ?? 0) + 1); - } - return Array.from(map.entries()); - }, [analysisColumns]); + const modelUsageRows = useMemo( + () => parseModelUsageRows(selectedExecution?.model_usage ?? null), + [selectedExecution?.model_usage], + ); const sideEffects = useMemo(() => { const values = selectedExecution?.analysis?.side_effect_column_names; return Array.isArray(values) @@ -300,6 +366,44 @@ export function ExecutionsView({ const totalPages = Math.max(1, Math.ceil(datasetTotal / datasetPageSize)); const canPageDataset = Boolean(selectedExecution?.jobId) && selectedExecution?.kind === "full"; + const recordsMetric = useMemo(() => { + if (!selectedExecution || selectedExecution.status !== "completed") { + return null; + } + if (typeof selectedExecution.analysis?.num_records === "number") { + return selectedExecution.analysis.num_records; + } + if (selectedExecution.datasetTotal > 0) { + return selectedExecution.datasetTotal; + } + if (selectedExecution.dataset.length > 0) { + return selectedExecution.dataset.length; + } + return null; + }, [selectedExecution]); + const totalMetric = useMemo(() => { + if (!selectedExecution || selectedExecution.status !== "completed") { + return null; + } + if (typeof selectedExecution.analysis?.target_num_records === "number") { + return selectedExecution.analysis.target_num_records; + } + return selectedExecution.rows > 0 ? selectedExecution.rows : null; + }, [selectedExecution]); + const showSummaryCards = selectedExecution?.status === "completed"; + const showProgressPanel = + selectedExecution?.status === "completed" || + (selectedExecution ? isInProgress(selectedExecution.status) : false); + const progressComplete = selectedExecution?.status === "completed"; + const progressPercent = selectedExecution?.progress?.percent ?? (progressComplete ? 100 : 0); + const terminalLines = selectedExecution?.log_lines ?? []; + + useEffect(() => { + if (!terminalRef.current) { + return; + } + terminalRef.current.scrollTop = terminalRef.current.scrollHeight; + }, [selectedExecution?.id, terminalLines.length]); return (
@@ -431,18 +535,49 @@ export function ExecutionsView({ )}
- {isInProgress(selectedExecution.status) && ( -
+ {showProgressPanel && ( +
-

- Run in progress -

-

- {formatPercent(selectedExecution.progress?.percent)} +

+ +

+ {progressComplete ? "Run completed" : "Run in progress"} +

+
+

+ {formatPercent(progressPercent)}

- -
+ +

Done: {selectedExecution.progress?.done ?? "--"}

@@ -457,7 +592,12 @@ export function ExecutionsView({

{selectedExecution.current_column && selectedExecution.column_progress && ( -

+

Column {selectedExecution.current_column}:{" "} {selectedExecution.column_progress.done ?? "--"}/ {selectedExecution.column_progress.total ?? "--"} ( @@ -481,13 +621,6 @@ export function ExecutionsView({

)} - {selectedExecution.status === "running" && ( -
- - -
- )} - {(selectedExecution.status === "completed" || isInProgress(selectedExecution.status)) && ( @@ -498,66 +631,111 @@ export function ExecutionsView({ {showRaw && Raw} -
-
-

Records

-

- {selectedExecution.analysis?.num_records ?? "--"} -

-
-
-

Target

-

- {selectedExecution.analysis?.target_num_records ?? "--"} -

-
-
-

Completion

-

- {formatPercent( - selectedExecution.analysis?.num_records && - selectedExecution.analysis?.target_num_records - ? (selectedExecution.analysis.num_records / - selectedExecution.analysis.target_num_records) * - 100 - : null, - )} -

-
-
-

Columns

-

- {analysisColumns.length > 0 ? analysisColumns.length : "--"} -

-
-
-
-

Column type breakdown

- {columnTypeCounts.length === 0 ? ( -

No analysis yet.

- ) : ( -
- {columnTypeCounts.map(([type, count]) => ( - - {type}: {count} - - ))} + {showSummaryCards && ( +
+
+
+
+

Records

+ +
+

+ {formatMetricValue(recordsMetric)} / {formatMetricValue(totalMetric)} +

+

+ generated / requested +

+
+
+
+

Model usage

+ +
+ {modelUsageRows.length === 0 ? ( +

No model usage yet.

+ ) : ( +
+ {modelUsageRows.map((usage) => ( +
+

|-- model: {usage.model}

+

+ |-- tokens: input={formatMetricValue(usage.input)}, output= + {formatMetricValue(usage.output)}, total= + {formatMetricValue(usage.total)}, tps= + {formatMetricValue(usage.tps)} +

+

+ |-- requests: success= + {formatMetricValue(usage.requestsSuccess)}, failed= + {formatMetricValue(usage.requestsFailed)}, total= + {formatMetricValue(usage.requestsTotal)}, rpm= + {formatMetricValue(usage.rpm)} +

+
+ ))} +
+ )} +
+
+
+

Dropped columns

+ +
+ {sideEffects.length === 0 ? ( +

None

+ ) : ( +
+ {sideEffects.map((name) => ( + + {name} + + ))} +
+ )} +
- )} -
-
-

Side-effect columns

- {sideEffects.length === 0 ? ( -

None.

- ) : ( -
- {sideEffects.map((name) => ( - - {name} - - ))} -
- )} +
+ )} +
+
+

Terminal output

+

+ {terminalLines.length} lines +

+
+
+ {terminalLines.length === 0 ? ( +

+ {isInProgress(selectedExecution.status) + ? "Waiting for logs..." + : "No logs captured."} +

+ ) : ( + terminalLines.map((line, index) => ( +

+ {line} +

+ )) + )} +
@@ -576,6 +754,8 @@ export function ExecutionsView({ Data type Unique Nulls + Input tok avg + Output tok avg @@ -586,6 +766,8 @@ export function ExecutionsView({ {column.simple_dtype} {column.num_unique ?? "--"} {column.num_null ?? "--"} + {column.input_tokens_mean ?? "--"} + {column.output_tokens_mean ?? "--"} ))} diff --git a/studio/frontend/src/features/recipe-studio/execution-types.ts b/studio/frontend/src/features/recipe-studio/execution-types.ts index 3a4d39eb18..d98ae7d99f 100644 --- a/studio/frontend/src/features/recipe-studio/execution-types.ts +++ b/studio/frontend/src/features/recipe-studio/execution-types.ts @@ -56,6 +56,8 @@ export type RecipeExecutionRecord = { lastEventId: number | null; // biome-ignore lint/style/useNamingConvention: backend schema artifact_path: string | null; + // biome-ignore lint/style/useNamingConvention: backend schema + log_lines: string[]; dataset: Record[]; datasetTotal: number; datasetPage: number; diff --git a/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts b/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts index ab8b864006..0c3a74758f 100644 --- a/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts +++ b/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts @@ -106,6 +106,9 @@ export function withExecutionDefaults( record: RecipeExecutionRecord, ): RecipeExecutionRecord { const dataset = Array.isArray(record.dataset) ? record.dataset : []; + const logLines = Array.isArray(record.log_lines) + ? record.log_lines.filter((line): line is string => typeof line === "string") + : []; const datasetPageSize = typeof record.datasetPageSize === "number" && record.datasetPageSize > 0 ? record.datasetPageSize @@ -122,6 +125,7 @@ export function withExecutionDefaults( return { ...record, dataset, + log_lines: logLines, datasetTotal, datasetPage, datasetPageSize, diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts index 6c53e58eb1..8c774a51be 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts @@ -8,6 +8,7 @@ import { getRecipeJobDataset, getRecipeJobStatus, streamRecipeJobEvents, + type JobEvent, validateRecipe, } from "../api"; import { listRecipeExecutions, saveRecipeExecution } from "../data/executions-db"; @@ -97,6 +98,65 @@ type JobCompletedEventPayload = { type?: unknown; }; +const MAX_LOG_LINES = 1500; + +function formatEventTime(ts: unknown): string { + if (typeof ts !== "number" || !Number.isFinite(ts)) { + return new Date().toLocaleTimeString(); + } + const ms = ts > 10_000_000_000 ? ts : ts * 1000; + return new Date(ms).toLocaleTimeString(); +} + +function appendLogLine(lines: string[], nextLine: string): string[] { + const next = [...lines, nextLine]; + if (next.length <= MAX_LOG_LINES) { + return next; + } + return next.slice(next.length - MAX_LOG_LINES); +} + +function toLogLine(event: JobEvent): string | null { + const eventType = + typeof event.payload.type === "string" ? event.payload.type : event.event; + const ts = formatEventTime(event.payload.ts); + + if (eventType === "log") { + const message = + typeof event.payload.message === "string" ? event.payload.message.trim() : ""; + if (!message) { + return null; + } + const level = + typeof event.payload.level === "string" && event.payload.level.length > 0 + ? event.payload.level.toUpperCase() + : "INFO"; + return `[${ts}] [${level}] ${message}`; + } + + if (eventType === "job.started") { + return `[${ts}] [INFO] Job started`; + } + if (eventType === "job.completed") { + return `[${ts}] [INFO] Job completed`; + } + if (eventType === "job.cancelling") { + return `[${ts}] [WARN] Cancellation requested`; + } + if (eventType === "job.cancelled") { + return `[${ts}] [WARN] Job cancelled`; + } + if (eventType === "job.error") { + const error = + typeof event.payload.error === "string" && event.payload.error.length > 0 + ? event.payload.error + : "Job failed"; + return `[${ts}] [ERROR] ${error}`; + } + + return null; +} + export function useRecipeStudioActions({ recipeId, initialRecipeName, @@ -282,6 +342,7 @@ export function useRecipeStudioActions({ model_usage: null, lastEventId: null, artifact_path: null, + log_lines: [], dataset: [], datasetTotal: 0, datasetPage: 1, @@ -323,15 +384,35 @@ export function useRecipeStudioActions({ jobId, signal: eventsAbortController.signal, onEvent: (event) => { + let changed = false; if (typeof event.id === "number") { latestExecution = { ...latestExecution, lastEventId: event.id, }; + changed = true; + } + + const logLine = toLogLine(event); + if (logLine) { + latestExecution = { + ...latestExecution, + log_lines: appendLogLine(latestExecution.log_lines, logLine), + }; + changed = true; } const eventType = typeof event.payload.type === "string" ? event.payload.type : event.event; + if (eventType === "job.started") { + latestExecution = { + ...latestExecution, + status: "active", + }; + upsertExecution(latestExecution); + return; + } + if (eventType === "job.completed") { lastStatus = "completed"; completedEventPayload = event.payload; @@ -372,6 +453,11 @@ export function useRecipeStudioActions({ status: "cancelling", }; upsertExecution(latestExecution); + return; + } + + if (changed) { + upsertExecution(latestExecution); } }, }).catch(() => { @@ -418,6 +504,36 @@ export function useRecipeStudioActions({ } if (lastStatus === "completed") { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const finalStatus = await getRecipeJobStatus(jobId); + latestExecution = { + ...latestExecution, + status: mapJobStatus(finalStatus.status), + rows: finalStatus.rows ?? latestExecution.rows, + stage: finalStatus.stage ?? latestExecution.stage, + current_column: finalStatus.current_column ?? latestExecution.current_column, + progress: + (normalizeObject(finalStatus.progress) as RecipeExecutionRecord["progress"]) ?? + latestExecution.progress, + column_progress: + (normalizeObject( + finalStatus.column_progress, + ) as RecipeExecutionRecord["column_progress"]) ?? + latestExecution.column_progress, + model_usage: normalizeObject(finalStatus.model_usage) ?? latestExecution.model_usage, + artifact_path: finalStatus.artifact_path ?? latestExecution.artifact_path, + error: finalStatus.error ?? latestExecution.error, + finishedAt: latestExecution.finishedAt ?? Date.now(), + }; + } catch { + break; + } + if (attempt < 2) { + await delay(250); + } + } + const eventAnalysis = completedEventPayload ? completedEventPayload["analysis"] : null; @@ -454,10 +570,37 @@ export function useRecipeStudioActions({ datasetResponse && typeof datasetResponse.total === "number" ? datasetResponse.total : latestExecution.datasetTotal; + const progressTotal = + typeof latestExecution.progress?.total === "number" && + latestExecution.progress.total > 0 + ? latestExecution.progress.total + : latestExecution.rows > 0 + ? latestExecution.rows + : rows; + const completedProgress: RecipeExecutionRecord["progress"] = { + ...(latestExecution.progress ?? {}), + done: progressTotal, + total: progressTotal, + percent: 100, + eta_sec: 0, + }; + const completedColumnProgress: RecipeExecutionRecord["column_progress"] = + latestExecution.column_progress && + typeof latestExecution.column_progress.total === "number" && + latestExecution.column_progress.total > 0 + ? { + ...latestExecution.column_progress, + done: latestExecution.column_progress.total, + percent: 100, + eta_sec: 0, + } + : latestExecution.column_progress; upsertExecution({ ...latestExecution, status: "completed", + progress: completedProgress, + column_progress: completedColumnProgress, analysis, dataset, datasetTotal,