refactor: extract and modularize execution tabs and helpers for enhanced code reusability and maintainability
This commit is contained in:
parent
d6921042b0
commit
b7dfa2b7e4
7 changed files with 818 additions and 615 deletions
|
|
@ -0,0 +1,54 @@
|
|||
import type { ReactElement } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AnalysisColumnStat } from "./executions-view-helpers";
|
||||
|
||||
type ExecutionColumnsTabProps = {
|
||||
analysisColumns: AnalysisColumnStat[];
|
||||
};
|
||||
|
||||
export function ExecutionColumnsTab({
|
||||
analysisColumns,
|
||||
}: ExecutionColumnsTabProps): ReactElement {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Column statistics</p>
|
||||
{analysisColumns.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No column statistics yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Column</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Data type</TableHead>
|
||||
<TableHead>Unique</TableHead>
|
||||
<TableHead>Nulls</TableHead>
|
||||
<TableHead>Input tok avg</TableHead>
|
||||
<TableHead>Output tok avg</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{analysisColumns.map((column) => (
|
||||
<TableRow key={column.column_name}>
|
||||
<TableCell>{column.column_name}</TableCell>
|
||||
<TableCell>{column.column_type}</TableCell>
|
||||
<TableCell>{column.simple_dtype}</TableCell>
|
||||
<TableCell>{column.num_unique ?? "--"}</TableCell>
|
||||
<TableCell>{column.num_null ?? "--"}</TableCell>
|
||||
<TableCell>{column.input_tokens_mean ?? "--"}</TableCell>
|
||||
<TableCell>{column.output_tokens_mean ?? "--"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import type { ReactElement } from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isExecutionInProgress } from "../../executions/execution-helpers";
|
||||
import type { RecipeExecutionRecord } from "../../execution-types";
|
||||
import { formatCellValue, isExpandableCellValue } from "./executions-view-helpers";
|
||||
|
||||
type ExecutionDataTabProps = {
|
||||
execution: RecipeExecutionRecord;
|
||||
datasetColumnNames: string[];
|
||||
hiddenDatasetColumns: string[];
|
||||
canPageDataset: boolean;
|
||||
currentDatasetPage: number;
|
||||
totalPages: number;
|
||||
tableColumns: ColumnDef<Record<string, unknown>>[];
|
||||
datasetRowsForTable: Record<string, unknown>[];
|
||||
visibleDatasetColumnNames: string[];
|
||||
expandedDatasetRows: Record<string, boolean>;
|
||||
selectedExecutionIdSafe: string | null;
|
||||
onSetHiddenColumns: (updater: (current: string[]) => string[]) => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
onToggleRowExpanded: (rowId: string) => void;
|
||||
};
|
||||
|
||||
export function ExecutionDataTab({
|
||||
execution,
|
||||
datasetColumnNames,
|
||||
hiddenDatasetColumns,
|
||||
canPageDataset,
|
||||
currentDatasetPage,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
datasetRowsForTable,
|
||||
visibleDatasetColumnNames,
|
||||
expandedDatasetRows,
|
||||
selectedExecutionIdSafe,
|
||||
onSetHiddenColumns,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onToggleRowExpanded,
|
||||
}: ExecutionDataTabProps): ReactElement {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">Dataset sample</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{datasetColumnNames.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="outline">
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
|
||||
{datasetColumnNames.map((columnName) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={columnName}
|
||||
checked={!hiddenDatasetColumns.includes(columnName)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onCheckedChange={(checked) => {
|
||||
onSetHiddenColumns((currentColumns) => {
|
||||
if (checked) {
|
||||
return currentColumns.filter((name) => name !== columnName);
|
||||
}
|
||||
return [...currentColumns, columnName];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{columnName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canPageDataset && (
|
||||
<>
|
||||
<span>
|
||||
Page {currentDatasetPage}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isExecutionInProgress(execution.status) || currentDatasetPage <= 1
|
||||
}
|
||||
onClick={onPrevPage}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isExecutionInProgress(execution.status) ||
|
||||
currentDatasetPage >= totalPages
|
||||
}
|
||||
onClick={onNextPage}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{execution.dataset.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No rows returned.</p>
|
||||
) : tableColumns.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All columns hidden. Use Columns to show at least one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={datasetRowsForTable}
|
||||
getRowClassName={(row, _rowIndex, rowId) => {
|
||||
const canExpand = visibleDatasetColumnNames.some((columnName) =>
|
||||
isExpandableCellValue(formatCellValue(row[columnName])),
|
||||
);
|
||||
if (!canExpand) {
|
||||
return undefined;
|
||||
}
|
||||
return cn(
|
||||
"cursor-pointer",
|
||||
expandedDatasetRows[rowId] ? "bg-primary/[0.05]" : "hover:bg-primary/[0.06]",
|
||||
);
|
||||
}}
|
||||
onRowClick={(row, _rowIndex, rowId) => {
|
||||
const canExpand = visibleDatasetColumnNames.some((columnName) =>
|
||||
isExpandableCellValue(formatCellValue(row[columnName])),
|
||||
);
|
||||
if (!canExpand || !selectedExecutionIdSafe) {
|
||||
return;
|
||||
}
|
||||
onToggleRowExpanded(rowId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import type { ReactElement, RefObject, UIEvent } from "react";
|
||||
import {
|
||||
Database01Icon,
|
||||
Database02Icon,
|
||||
Flag02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { isExecutionInProgress } from "../../executions/execution-helpers";
|
||||
import type { RecipeExecutionRecord } from "../../execution-types";
|
||||
import type { ModelUsageRow } from "./executions-view-helpers";
|
||||
import { formatMetricValue } from "./executions-view-helpers";
|
||||
|
||||
type ExecutionOverviewTabProps = {
|
||||
execution: RecipeExecutionRecord;
|
||||
showSummaryCards: boolean;
|
||||
recordsMetric: number | null;
|
||||
totalMetric: number | null;
|
||||
runDuration: string;
|
||||
columnCount: number;
|
||||
llmColumnCount: number;
|
||||
nullRate: number | null;
|
||||
sideEffects: string[];
|
||||
lowUniquenessColumns: string[];
|
||||
modelUsageRows: ModelUsageRow[];
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
terminalLines: string[];
|
||||
terminalRef: RefObject<HTMLDivElement | null>;
|
||||
onTerminalScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
export function ExecutionOverviewTab({
|
||||
execution,
|
||||
showSummaryCards,
|
||||
recordsMetric,
|
||||
totalMetric,
|
||||
runDuration,
|
||||
columnCount,
|
||||
llmColumnCount,
|
||||
nullRate,
|
||||
sideEffects,
|
||||
lowUniquenessColumns,
|
||||
modelUsageRows,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
terminalLines,
|
||||
terminalRef,
|
||||
onTerminalScroll,
|
||||
}: ExecutionOverviewTabProps): ReactElement {
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{showSummaryCards && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="h-full rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Run summary</p>
|
||||
<HugeiconsIcon
|
||||
icon={Database01Icon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>
|
||||
Records:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(recordsMetric)} / {formatMetricValue(totalMetric)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Duration: <span className="font-semibold">{runDuration}</span>
|
||||
</p>
|
||||
<p>
|
||||
Columns analyzed:{" "}
|
||||
<span className="font-semibold">{formatMetricValue(columnCount)}</span>
|
||||
</p>
|
||||
<p>
|
||||
Final stage:{" "}
|
||||
<span className="font-semibold">{execution.stage ?? "--"}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Insights</p>
|
||||
<HugeiconsIcon
|
||||
icon={Database02Icon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<p>
|
||||
LLM columns:{" "}
|
||||
<span className="font-semibold">{formatMetricValue(llmColumnCount)}</span>
|
||||
</p>
|
||||
<p>
|
||||
Null rate: <span className="font-semibold">{nullRate?.toFixed(1) ?? "--"}%</span>
|
||||
</p>
|
||||
<p>
|
||||
Dropped columns:{" "}
|
||||
<span className="font-semibold">{formatMetricValue(sideEffects.length)}</span>
|
||||
</p>
|
||||
{sideEffects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sideEffects.map((name) => (
|
||||
<Badge key={name} variant="outline">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Low uniqueness flags:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(lowUniquenessColumns.length)}
|
||||
</span>
|
||||
</p>
|
||||
{lowUniquenessColumns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lowUniquenessColumns.slice(0, 3).map((name) => (
|
||||
<Badge key={name} variant="secondary">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{lowUniquenessColumns.length > 3 && (
|
||||
<Badge variant="secondary">
|
||||
+{lowUniquenessColumns.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Model usage</p>
|
||||
<HugeiconsIcon icon={Flag02Icon} className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{modelUsageRows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No model usage yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded border bg-muted/30 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Total input</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatMetricValue(totalInputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded border bg-muted/30 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Total output</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatMetricValue(totalOutputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Input</TableHead>
|
||||
<TableHead className="text-right">Output</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modelUsageRows.map((usage) => (
|
||||
<TableRow key={usage.model}>
|
||||
<TableCell className="max-w-[320px] truncate">{usage.model}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatMetricValue(usage.input)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatMetricValue(usage.output)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl corner-squircle border">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<p className="text-sm font-semibold">Terminal output</p>
|
||||
<p className="text-xs text-muted-foreground">{terminalLines.length} lines</p>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="max-h-72 overflow-auto bg-zinc-900/80 px-3 py-2 font-mono text-xs text-zinc-200"
|
||||
onScroll={onTerminalScroll}
|
||||
>
|
||||
{terminalLines.length === 0 ? (
|
||||
<p className="text-zinc-400">
|
||||
{isExecutionInProgress(execution.status)
|
||||
? "Waiting for logs..."
|
||||
: "No logs captured."}
|
||||
</p>
|
||||
) : (
|
||||
terminalLines.map((line, index) => (
|
||||
<p
|
||||
key={`${index}-${line.slice(0, 24)}`}
|
||||
className="whitespace-pre-wrap break-words leading-relaxed"
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
type ExecutionRawTabProps = {
|
||||
rawExecution: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function ExecutionRawTab({
|
||||
rawExecution,
|
||||
}: ExecutionRawTabProps): ReactElement {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Raw execution</p>
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||
{JSON.stringify(rawExecution, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RecipeExecutionRecord } from "../../execution-types";
|
||||
import {
|
||||
formatStatus,
|
||||
formatTimestamp,
|
||||
statusRightBorder,
|
||||
statusTone,
|
||||
} from "./executions-view-helpers";
|
||||
|
||||
type ExecutionSidebarProps = {
|
||||
executions: RecipeExecutionRecord[];
|
||||
selectedExecutionId: string | null;
|
||||
onSelectExecution: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ExecutionSidebar({
|
||||
executions,
|
||||
selectedExecutionId,
|
||||
onSelectExecution,
|
||||
}: ExecutionSidebarProps): ReactElement {
|
||||
return (
|
||||
<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>
|
||||
</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 border-r-4 p-3 text-left",
|
||||
selectedExecutionId === execution.id
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "hover:bg-muted/40",
|
||||
statusRightBorder(execution.status),
|
||||
)}
|
||||
>
|
||||
<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))}
|
||||
>
|
||||
{formatStatus(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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import type {
|
||||
RecipeExecutionAnalysis,
|
||||
RecipeExecutionStatus,
|
||||
} from "../../execution-types";
|
||||
import { isExecutionInProgress } from "../../executions/execution-helpers";
|
||||
|
||||
export type AnalysisColumnStat = {
|
||||
column_name: string;
|
||||
column_type: string;
|
||||
simple_dtype: string;
|
||||
num_unique: number | null;
|
||||
num_null: number | null;
|
||||
input_tokens_mean: number | null;
|
||||
output_tokens_mean: number | null;
|
||||
};
|
||||
|
||||
export type ModelUsageRow = {
|
||||
model: string;
|
||||
input: number | null;
|
||||
output: number | null;
|
||||
};
|
||||
|
||||
export const PREVIEW_DATASET_PAGE_SIZE = 20;
|
||||
export const TERMINAL_STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
|
||||
export function formatTimestamp(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpandableCellValue(value: string): boolean {
|
||||
return value.length > 180;
|
||||
}
|
||||
|
||||
export function truncateCellValue(value: string): string {
|
||||
if (value.length <= 180) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 180).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseString(value: unknown): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : "--";
|
||||
}
|
||||
|
||||
export function parseAnalysisColumns(
|
||||
analysis: RecipeExecutionAnalysis | null,
|
||||
): AnalysisColumnStat[] {
|
||||
const items = Array.isArray(analysis?.column_statistics)
|
||||
? analysis.column_statistics
|
||||
: [];
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
column_name: parseString(row.column_name),
|
||||
column_type: parseString(row.column_type),
|
||||
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);
|
||||
}
|
||||
|
||||
export function statusTone(status: RecipeExecutionStatus): string {
|
||||
if (status === "completed") {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
if (status === "error" || status === "cancelled") {
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
if (isExecutionInProgress(status)) {
|
||||
return "bg-amber-100 text-amber-700";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export function statusRightBorder(status: RecipeExecutionStatus): string {
|
||||
if (status === "completed") {
|
||||
return "border-r-emerald-500";
|
||||
}
|
||||
if (status === "error" || status === "cancelled") {
|
||||
return "border-r-red-500";
|
||||
}
|
||||
if (isExecutionInProgress(status)) {
|
||||
return "border-r-amber-500";
|
||||
}
|
||||
return "border-r-border";
|
||||
}
|
||||
|
||||
export function formatStatus(status: RecipeExecutionStatus): string {
|
||||
if (status === "cancelled") {
|
||||
return "cancelled";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "--";
|
||||
}
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatDuration(startedAt: number, finishedAt: number | null): string {
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return "--";
|
||||
}
|
||||
const seconds = Math.round((finishedAt - startedAt) / 1000);
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatMetricValue(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "--";
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function parseModelUsageRows(
|
||||
value: Record<string, unknown> | 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<string, unknown>;
|
||||
const tokens =
|
||||
modelObj.tokens &&
|
||||
typeof modelObj.tokens === "object" &&
|
||||
!Array.isArray(modelObj.tokens)
|
||||
? (modelObj.tokens as Record<string, unknown>)
|
||||
: 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),
|
||||
};
|
||||
})
|
||||
.filter((item): item is ModelUsageRow => item !== null);
|
||||
}
|
||||
|
|
@ -2,38 +2,37 @@ 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";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
RecipeExecutionAnalysis,
|
||||
RecipeExecutionRecord,
|
||||
RecipeExecutionStatus,
|
||||
} from "../../execution-types";
|
||||
import { isExecutionInProgress } from "../../executions/execution-helpers";
|
||||
import { ExecutionColumnsTab } from "./execution-columns-tab";
|
||||
import { ExecutionDataTab } from "./execution-data-tab";
|
||||
import { ExecutionOverviewTab } from "./execution-overview-tab";
|
||||
import { ExecutionRawTab } from "./execution-raw-tab";
|
||||
import { ExecutionSidebar } from "./execution-sidebar";
|
||||
import {
|
||||
PREVIEW_DATASET_PAGE_SIZE,
|
||||
TERMINAL_STICKY_BOTTOM_THRESHOLD_PX,
|
||||
formatCellValue,
|
||||
formatDuration,
|
||||
formatPercent,
|
||||
formatStatus,
|
||||
formatTimestamp,
|
||||
isExpandableCellValue,
|
||||
parseAnalysisColumns,
|
||||
parseModelUsageRows,
|
||||
statusTone,
|
||||
truncateCellValue,
|
||||
} from "./executions-view-helpers";
|
||||
|
||||
type ExecutionsViewProps = {
|
||||
executions: RecipeExecutionRecord[];
|
||||
|
|
@ -44,170 +43,6 @@ type ExecutionsViewProps = {
|
|||
onLoadDatasetPage: (id: string, page: number) => void;
|
||||
};
|
||||
|
||||
type AnalysisColumnStat = {
|
||||
column_name: string;
|
||||
column_type: string;
|
||||
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;
|
||||
};
|
||||
|
||||
const PREVIEW_DATASET_PAGE_SIZE = 20;
|
||||
const TERMINAL_STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
|
||||
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 isExpandableCellValue(value: string): boolean {
|
||||
return value.length > 180;
|
||||
}
|
||||
|
||||
function truncateCellValue(value: string): string {
|
||||
if (value.length <= 180) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 180).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseString(value: unknown): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : "--";
|
||||
}
|
||||
|
||||
function parseAnalysisColumns(analysis: RecipeExecutionAnalysis | null): AnalysisColumnStat[] {
|
||||
const items = Array.isArray(analysis?.column_statistics)
|
||||
? analysis.column_statistics
|
||||
: [];
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
column_name: parseString(row.column_name),
|
||||
column_type: parseString(row.column_type),
|
||||
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);
|
||||
}
|
||||
|
||||
function statusTone(status: RecipeExecutionStatus): string {
|
||||
if (status === "completed") {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
if (status === "error" || status === "cancelled") {
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
if (isExecutionInProgress(status)) {
|
||||
return "bg-amber-100 text-amber-700";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
function statusRightBorder(status: RecipeExecutionStatus): string {
|
||||
if (status === "completed") {
|
||||
return "border-r-emerald-500";
|
||||
}
|
||||
if (status === "error" || status === "cancelled") {
|
||||
return "border-r-red-500";
|
||||
}
|
||||
if (isExecutionInProgress(status)) {
|
||||
return "border-r-amber-500";
|
||||
}
|
||||
return "border-r-border";
|
||||
}
|
||||
|
||||
function formatStatus(status: RecipeExecutionStatus): string {
|
||||
if (status === "cancelled") {
|
||||
return "cancelled";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "--";
|
||||
}
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: number, finishedAt: number | null): string {
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return "--";
|
||||
}
|
||||
const seconds = Math.round((finishedAt - startedAt) / 1000);
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
const tokens =
|
||||
modelObj.tokens && typeof modelObj.tokens === "object" && !Array.isArray(modelObj.tokens)
|
||||
? (modelObj.tokens as Record<string, unknown>)
|
||||
: 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),
|
||||
};
|
||||
})
|
||||
.filter((item): item is ModelUsageRow => item !== null);
|
||||
}
|
||||
|
||||
export function ExecutionsView({
|
||||
executions,
|
||||
selectedExecutionId,
|
||||
|
|
@ -476,53 +311,11 @@ export function ExecutionsView({
|
|||
|
||||
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>
|
||||
</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 border-r-4 p-3 text-left",
|
||||
selectedExecutionId === execution.id
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "hover:bg-muted/40",
|
||||
statusRightBorder(execution.status),
|
||||
)}
|
||||
>
|
||||
<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))}
|
||||
>
|
||||
{formatStatus(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>
|
||||
<ExecutionSidebar
|
||||
executions={executions}
|
||||
selectedExecutionId={selectedExecutionId}
|
||||
onSelectExecution={onSelectExecution}
|
||||
/>
|
||||
<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">
|
||||
|
|
@ -540,7 +333,9 @@ export function ExecutionsView({
|
|||
</Badge>
|
||||
<span>{selectedExecution.rows} rows</span>
|
||||
<span>Started {formatTimestamp(selectedExecution.createdAt)}</span>
|
||||
<span>Duration {formatDuration(selectedExecution.createdAt, selectedExecution.finishedAt)}</span>
|
||||
<span>
|
||||
Duration {formatDuration(selectedExecution.createdAt, selectedExecution.finishedAt)}
|
||||
</span>
|
||||
{selectedExecution.stage && (
|
||||
<span>
|
||||
Stage: {selectedExecution.stage}
|
||||
|
|
@ -595,18 +390,10 @@ export function ExecutionsView({
|
|||
progressComplete ? "text-emerald-900" : "text-amber-900",
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
Done: {selectedExecution.progress?.done ?? "--"}
|
||||
</p>
|
||||
<p>
|
||||
Total: {selectedExecution.progress?.total ?? "--"}
|
||||
</p>
|
||||
<p>
|
||||
Rate: {selectedExecution.progress?.rate ?? "--"} rec/s
|
||||
</p>
|
||||
<p>
|
||||
ETA: {selectedExecution.progress?.eta_sec ?? "--"} s
|
||||
</p>
|
||||
<p>Done: {selectedExecution.progress?.done ?? "--"}</p>
|
||||
<p>Total: {selectedExecution.progress?.total ?? "--"}</p>
|
||||
<p>Rate: {selectedExecution.progress?.rate ?? "--"} rec/s</p>
|
||||
<p>ETA: {selectedExecution.progress?.eta_sec ?? "--"} s</p>
|
||||
</div>
|
||||
{selectedExecution.current_column && selectedExecution.column_progress && (
|
||||
<p
|
||||
|
|
@ -659,380 +446,96 @@ export function ExecutionsView({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="overview" className="mt-3 space-y-3">
|
||||
{showSummaryCards && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="h-full rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Run summary</p>
|
||||
<HugeiconsIcon
|
||||
icon={Database01Icon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>
|
||||
Records:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(recordsMetric)} / {formatMetricValue(totalMetric)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Duration: <span className="font-semibold">{runDuration}</span>
|
||||
</p>
|
||||
<p>
|
||||
Columns analyzed:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(columnCount)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Final stage:{" "}
|
||||
<span className="font-semibold">
|
||||
{selectedExecution.stage ?? "--"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Insights</p>
|
||||
<HugeiconsIcon
|
||||
icon={Database02Icon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<p>
|
||||
LLM columns:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(llmColumnCount)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Null rate:{" "}
|
||||
<span className="font-semibold">{formatPercent(nullRate)}</span>
|
||||
</p>
|
||||
<p>
|
||||
Dropped columns:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(sideEffects.length)}
|
||||
</span>
|
||||
</p>
|
||||
{sideEffects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sideEffects.map((name) => (
|
||||
<Badge key={name} variant="outline">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Low uniqueness flags:{" "}
|
||||
<span className="font-semibold">
|
||||
{formatMetricValue(lowUniquenessColumns.length)}
|
||||
</span>
|
||||
</p>
|
||||
{lowUniquenessColumns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lowUniquenessColumns.slice(0, 3).map((name) => (
|
||||
<Badge key={name} variant="secondary">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{lowUniquenessColumns.length > 3 && (
|
||||
<Badge variant="secondary">
|
||||
+{lowUniquenessColumns.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Model usage</p>
|
||||
<HugeiconsIcon
|
||||
icon={Flag02Icon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
{modelUsageRows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No model usage yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded border bg-muted/30 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Total input</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatMetricValue(totalInputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded border bg-muted/30 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Total output</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatMetricValue(totalOutputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Input</TableHead>
|
||||
<TableHead className="text-right">Output</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modelUsageRows.map((usage) => (
|
||||
<TableRow key={usage.model}>
|
||||
<TableCell className="max-w-[320px] truncate">
|
||||
{usage.model}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatMetricValue(usage.input)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatMetricValue(usage.output)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl corner-squircle border">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<p className="text-sm font-semibold">Terminal output</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{terminalLines.length} lines
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="max-h-72 overflow-auto bg-zinc-900/80 px-3 py-2 font-mono text-xs text-zinc-200"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
shouldStickTerminalToBottomRef.current =
|
||||
distanceFromBottom <= TERMINAL_STICKY_BOTTOM_THRESHOLD_PX;
|
||||
}}
|
||||
>
|
||||
{terminalLines.length === 0 ? (
|
||||
<p className="text-zinc-400">
|
||||
{isExecutionInProgress(selectedExecution.status)
|
||||
? "Waiting for logs..."
|
||||
: "No logs captured."}
|
||||
</p>
|
||||
) : (
|
||||
terminalLines.map((line, index) => (
|
||||
<p
|
||||
key={`${index}-${line.slice(0, 24)}`}
|
||||
className="whitespace-pre-wrap break-words leading-relaxed"
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="overview">
|
||||
<ExecutionOverviewTab
|
||||
execution={selectedExecution}
|
||||
showSummaryCards={showSummaryCards}
|
||||
recordsMetric={recordsMetric}
|
||||
totalMetric={totalMetric}
|
||||
runDuration={runDuration}
|
||||
columnCount={columnCount}
|
||||
llmColumnCount={llmColumnCount}
|
||||
nullRate={nullRate}
|
||||
sideEffects={sideEffects}
|
||||
lowUniquenessColumns={lowUniquenessColumns}
|
||||
modelUsageRows={modelUsageRows}
|
||||
totalInputTokens={totalInputTokens}
|
||||
totalOutputTokens={totalOutputTokens}
|
||||
terminalLines={terminalLines}
|
||||
terminalRef={terminalRef}
|
||||
onTerminalScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
shouldStickTerminalToBottomRef.current =
|
||||
distanceFromBottom <= TERMINAL_STICKY_BOTTOM_THRESHOLD_PX;
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="columns" className="mt-3">
|
||||
<div className="rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Column statistics</p>
|
||||
{analysisColumns.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No column statistics yet.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Column</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Data type</TableHead>
|
||||
<TableHead>Unique</TableHead>
|
||||
<TableHead>Nulls</TableHead>
|
||||
<TableHead>Input tok avg</TableHead>
|
||||
<TableHead>Output tok avg</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{analysisColumns.map((column) => (
|
||||
<TableRow key={column.column_name}>
|
||||
<TableCell>{column.column_name}</TableCell>
|
||||
<TableCell>{column.column_type}</TableCell>
|
||||
<TableCell>{column.simple_dtype}</TableCell>
|
||||
<TableCell>{column.num_unique ?? "--"}</TableCell>
|
||||
<TableCell>{column.num_null ?? "--"}</TableCell>
|
||||
<TableCell>{column.input_tokens_mean ?? "--"}</TableCell>
|
||||
<TableCell>{column.output_tokens_mean ?? "--"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="columns">
|
||||
<ExecutionColumnsTab analysisColumns={analysisColumns} />
|
||||
</TabsContent>
|
||||
<TabsContent value="data" className="mt-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">Dataset sample</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{datasetColumnNames.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="outline">
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
|
||||
{datasetColumnNames.map((columnName) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={columnName}
|
||||
checked={!hiddenDatasetColumns.includes(columnName)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onCheckedChange={(checked) => {
|
||||
const selectedId = selectedExecution?.id;
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setHiddenDatasetColumnsByExecution((current) => {
|
||||
const currentColumns = current[selectedId] ?? [];
|
||||
const nextColumns = checked
|
||||
? currentColumns.filter((name) => name !== columnName)
|
||||
: [...currentColumns, columnName];
|
||||
return {
|
||||
...current,
|
||||
[selectedId]: nextColumns,
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{columnName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canPageDataset && selectedExecution && (
|
||||
<>
|
||||
<span>
|
||||
Page {currentDatasetPage}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isExecutionInProgress(selectedExecution.status) ||
|
||||
currentDatasetPage <= 1
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedExecution.kind === "preview") {
|
||||
const selectedId = selectedExecution.id;
|
||||
setPreviewDatasetPageByExecution((current) => ({
|
||||
...current,
|
||||
[selectedId]: Math.max(1, currentDatasetPage - 1),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
onLoadDatasetPage(selectedExecution.id, currentDatasetPage - 1);
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isExecutionInProgress(selectedExecution.status) ||
|
||||
currentDatasetPage >= totalPages
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedExecution.kind === "preview") {
|
||||
const selectedId = selectedExecution.id;
|
||||
setPreviewDatasetPageByExecution((current) => ({
|
||||
...current,
|
||||
[selectedId]: Math.min(totalPages, currentDatasetPage + 1),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
onLoadDatasetPage(selectedExecution.id, currentDatasetPage + 1);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedExecution.dataset.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No rows returned.</p>
|
||||
) : tableColumns.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All columns hidden. Use Columns to show at least one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={datasetRowsForTable}
|
||||
getRowClassName={(row, _rowIndex, rowId) => {
|
||||
const canExpand = visibleDatasetColumnNames.some((columnName) =>
|
||||
isExpandableCellValue(formatCellValue(row[columnName])),
|
||||
);
|
||||
if (!canExpand) {
|
||||
return undefined;
|
||||
}
|
||||
return cn(
|
||||
"cursor-pointer",
|
||||
expandedDatasetRows[rowId]
|
||||
? "bg-primary/[0.05]"
|
||||
: "hover:bg-primary/[0.06]",
|
||||
);
|
||||
}}
|
||||
onRowClick={(row, _rowIndex, rowId) => {
|
||||
const canExpand = visibleDatasetColumnNames.some((columnName) =>
|
||||
isExpandableCellValue(formatCellValue(row[columnName])),
|
||||
);
|
||||
if (!canExpand || !selectedExecutionIdSafe) {
|
||||
return;
|
||||
}
|
||||
setExpandedDatasetRowsByExecution((current) => {
|
||||
const rows = current[selectedExecutionIdSafe] ?? {};
|
||||
return {
|
||||
...current,
|
||||
[selectedExecutionIdSafe]: {
|
||||
...rows,
|
||||
[rowId]: !rows[rowId],
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TabsContent value="data">
|
||||
<ExecutionDataTab
|
||||
execution={selectedExecution}
|
||||
datasetColumnNames={datasetColumnNames}
|
||||
hiddenDatasetColumns={hiddenDatasetColumns}
|
||||
canPageDataset={canPageDataset}
|
||||
currentDatasetPage={currentDatasetPage}
|
||||
totalPages={totalPages}
|
||||
tableColumns={tableColumns}
|
||||
datasetRowsForTable={datasetRowsForTable}
|
||||
visibleDatasetColumnNames={visibleDatasetColumnNames}
|
||||
expandedDatasetRows={expandedDatasetRows}
|
||||
selectedExecutionIdSafe={selectedExecutionIdSafe}
|
||||
onSetHiddenColumns={(updater) => {
|
||||
const selectedId = selectedExecution.id;
|
||||
setHiddenDatasetColumnsByExecution((current) => {
|
||||
const currentColumns = current[selectedId] ?? [];
|
||||
return {
|
||||
...current,
|
||||
[selectedId]: updater(currentColumns),
|
||||
};
|
||||
});
|
||||
}}
|
||||
onPrevPage={() => {
|
||||
if (selectedExecution.kind === "preview") {
|
||||
const selectedId = selectedExecution.id;
|
||||
setPreviewDatasetPageByExecution((current) => ({
|
||||
...current,
|
||||
[selectedId]: Math.max(1, currentDatasetPage - 1),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
onLoadDatasetPage(selectedExecution.id, currentDatasetPage - 1);
|
||||
}}
|
||||
onNextPage={() => {
|
||||
if (selectedExecution.kind === "preview") {
|
||||
const selectedId = selectedExecution.id;
|
||||
setPreviewDatasetPageByExecution((current) => ({
|
||||
...current,
|
||||
[selectedId]: Math.min(totalPages, currentDatasetPage + 1),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
onLoadDatasetPage(selectedExecution.id, currentDatasetPage + 1);
|
||||
}}
|
||||
onToggleRowExpanded={(rowId) => {
|
||||
setExpandedDatasetRowsByExecution((current) => {
|
||||
const rows = current[selectedExecution.id] ?? {};
|
||||
return {
|
||||
...current,
|
||||
[selectedExecution.id]: {
|
||||
...rows,
|
||||
[rowId]: !rows[rowId],
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="raw" className="mt-3">
|
||||
<div className="rounded-xl border p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Raw execution</p>
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||
{JSON.stringify(rawExecution, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<TabsContent value="raw">
|
||||
<ExecutionRawTab rawExecution={rawExecution} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue