Fix: UI Bug Burndown, Part I (#1774)

* fix: horrific recursive query performance

* feat: start refactoring waterfall a bit

* fix: more waterfall cleanup

* fix: overflow

* fix: recenter button for dag view

* fix: button order
This commit is contained in:
Matt Kaye
2025-05-27 14:07:07 -04:00
committed by GitHub
parent 8846610568
commit 521c5f430f
8 changed files with 310 additions and 234 deletions

View File

@@ -16,6 +16,7 @@ import { RunDetailProvider, useRunDetail } from '@/next/hooks/use-run-detail';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import { Button } from '@/next/components/ui/button';
import { Fullscreen } from 'lucide-react';
const connectionLineStyleDark = { stroke: '#fff' };
const connectionLineStyleLight = { stroke: '#000' };
@@ -228,6 +229,7 @@ function WorkflowRunVisualizerContent({
if (!reactFlowInstance.current) {
return;
}
const node = layoutedNodes?.find(
(n: Node) => n.data.taskRun?.taskExternalId === selectedTaskId,
);
@@ -239,6 +241,8 @@ function WorkflowRunVisualizerContent({
duration: 800,
});
lastCenteredTaskId.current = selectedTaskId;
} else {
reactFlowInstance.current.fitView();
}
}, 1);
}, [selectedTaskId, layoutedNodes]);
@@ -311,20 +315,30 @@ function WorkflowRunVisualizerContent({
}
snapToGrid={true}
/>
<Button
variant="ghost"
size="icon"
onClick={toggleExpand}
className="absolute bottom-2 right-2 z-20"
tooltip={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
<span className="sr-only">{isExpanded ? 'Collapse' : 'Expand'}</span>
</Button>
<div className="flex flex-col absolute bottom-2 right-2 z-20">
<Button
variant="ghost"
size="icon"
onClick={recenter}
tooltip={'Recenter'}
>
<Fullscreen className="size-4" />
<span className="sr-only">{'Recenter'}</span>
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleExpand}
tooltip={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
<span className="sr-only">{isExpanded ? 'Collapse' : 'Expand'}</span>
</Button>
</div>
</div>
);
}

View File

@@ -49,10 +49,13 @@ export function RunId({
},
);
const displayNameIdPrefix = splitTime(displayName);
const friendlyDisplayName = displayNameIdPrefix || displayName;
const name = isTaskRun
? getFriendlyTaskRunId(taskRun)
: displayName && id
? splitTime(displayName) + '-' + id.split('-')[0]
? friendlyDisplayName + '-' + id.split('-')[0]
: getFriendlyWorkflowRunId(wfRun);
const handleDoubleClick = () => {

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback } from 'react';
import {
Bar,
BarChart,
@@ -9,7 +9,7 @@ import {
ResponsiveContainer,
Cell,
} from 'recharts';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, Drill, Loader } from 'lucide-react';
import { ChartContainer, ChartTooltipContent } from '@/components/ui/chart';
import { V1TaskStatus, V1TaskTiming } from '@/lib/api';
@@ -19,15 +19,19 @@ import { ROUTES } from '@/next/lib/routes';
import { useRunDetail } from '@/next/hooks/use-run-detail';
import { Button } from '../ui/button';
import { RunId } from '../runs/run-id';
import {
BsArrowDownRightCircle,
BsCircle,
BsArrowUpLeftCircle,
} from 'react-icons/bs';
import { BsArrowUpLeftCircle } from 'react-icons/bs';
import { Skeleton } from '../ui/skeleton';
import { useCurrentTenantId } from '@/next/hooks/use-tenant';
import {
TooltipProvider,
Tooltip as BaseTooltip,
TooltipContent,
TooltipTrigger,
} from '../ui/tooltip';
interface ProcessedTaskData {
id: string;
taskExternalId: string;
workflowRunId?: string;
taskDisplayName: string;
parentId?: string;
@@ -35,8 +39,8 @@ interface ProcessedTaskData {
depth: number;
isExpanded: boolean;
offset: number;
queuedDuration: number;
ranDuration: number;
queuedDuration: number | null;
ranDuration: number | null;
status: V1TaskStatus;
taskId: number; // Added for tie-breaking
attempt: number;
@@ -503,9 +507,9 @@ export function Waterfall({
}
// Find the global minimum time (queuedAt or startedAt) among visible tasks
let globalMinTime = Number.MAX_SAFE_INTEGER;
visibleTasks.forEach((id) => {
const globalMinTime = [...visibleTasks].reduce((acc, id) => {
const task = taskMap.get(id);
if (task) {
// Use queuedAt if available, otherwise use startedAt
const minTime = task.queuedAt
@@ -514,25 +518,32 @@ export function Waterfall({
? new Date(task.startedAt).getTime()
: null;
if (minTime !== null && minTime < globalMinTime) {
globalMinTime = minTime;
if (minTime !== null && minTime < acc) {
return minTime;
}
}
});
return acc;
}, Number.MAX_SAFE_INTEGER);
// Create the processed data for rendering
const data = Array.from(visibleTasks)
const data = [...visibleTasks]
.map((id) => {
const task = taskMap.get(id);
if (!task || !task.startedAt) {
if (!task) {
return null;
}
// Handle missing queuedAt by defaulting to startedAt (no queue time)
const queuedAt = task.queuedAt
? new Date(task.queuedAt).getTime()
: new Date(task.startedAt).getTime();
const startedAt = new Date(task.startedAt).getTime();
: task.startedAt
? new Date(task.startedAt).getTime()
: new Date(task.taskInsertedAt).getTime();
const startedAt = task.startedAt
? new Date(task.startedAt).getTime()
: null;
// For running tasks, always use current time as finishedAt
const now = new Date().getTime();
@@ -545,6 +556,7 @@ export function Waterfall({
return {
id: task.metadata.id,
taskExternalId: task.taskExternalId,
taskDisplayName: task.taskDisplayName,
parentId: task.parentTaskExternalId,
hasChildren: taskHasChildrenMap.get(task.metadata.id) || false,
@@ -554,8 +566,13 @@ export function Waterfall({
// Chart data
offset: (queuedAt - globalMinTime) / 1000, // in seconds
// If queuedAt equals startedAt (due to our fallback logic), then queuedDuration will be 0
queuedDuration: task.queuedAt ? (startedAt - queuedAt) / 1000 : 0, // in seconds
ranDuration: (finishedAt - startedAt) / 1000, // in seconds
queuedDuration: startedAt
? task.queuedAt
? (startedAt - queuedAt) / 1000
: 0
: null, // in seconds
ranDuration:
startedAt && finishedAt ? (finishedAt - startedAt) / 1000 : null, // in seconds
status: task.status,
taskId: task.taskId, // Add taskId for tie-breaking in sorting
attempt: task.attempt || 1,
@@ -583,159 +600,52 @@ export function Waterfall({
return { data, taskPathMap: new Map() };
}, [taskData, expandedTasks, autoExpandedInitially, taskRelationships]); // Only recompute when dependencies change
// Custom tick renderer with expand/collapse buttons
const renderTick = (props: {
x: number;
y: number;
payload: { value: string };
}) => {
const { x, y, payload } = props;
const task = processedData.data.find(
(t) => t.taskDisplayName === payload.value,
);
if (!task) {
// Return empty element instead of null
return <g transform={`translate(${x},${y})`}></g>;
// Handler for bar click events
const handleBarClick = (data: any) => {
if (data && data.id) {
// Handle task selection for sidebar
if (handleTaskSelect) {
handleTaskSelect(data.id, data.workflowRunId);
}
// Handle expansion if the task has children
if (data.hasChildren) {
openTask(data.id, data.depth);
}
}
const indentation = task.depth * 12; // 12px indentation per level
return (
<g transform={`translate(${x},${y})`}>
<foreignObject
x={-160} // Start position (right aligned)
y={-10} // Vertically center
width={160}
height={20}
>
<div
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: `${indentation}px`,
height: '100%',
}}
className="group"
>
{/* Expand/collapse button */}
<div
style={{
cursor: task.hasChildren ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
marginRight: '4px',
}}
onClick={() =>
task.hasChildren &&
toggleTask(task.id, task.hasChildren, task.depth)
}
>
{task.hasChildren &&
(task.isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
))}
</div>
{/* Task label */}
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '12px',
textAlign: 'left',
flexGrow: 1,
cursor: 'pointer',
}}
className=" flex items-center gap-2"
onClick={() => handleBarClick(task)}
>
<RunId
displayName={task.taskDisplayName}
id={task.id}
onClick={() => handleBarClick(task)}
className={task.id === selectedTaskId ? 'underline' : ''}
attempt={task.attempt}
/>
</div>
{workflowRunId === task.workflowRunId ? (
task.parentId ? (
<Link
to={ROUTES.runs.detailWithSheet(tenantId, task.parentId, {
type: 'task-detail',
props: {
selectedWorkflowRunId: task.workflowRunId,
selectedTaskId: task.id,
},
})}
onClick={(e) => e.stopPropagation()}
>
<Button
tooltip="Scope out to parent task"
variant="link"
size="icon"
className="group-hover:opacity-100 opacity-0 transition-opacity duration-200"
>
<BsArrowUpLeftCircle className="w-4 h-4 transform" />
</Button>
</Link>
) : (
<Button
tooltip="No parent task, this is a root task"
variant="link"
size="icon"
className="group-hover:opacity-100 opacity-0 transition-opacity duration-200"
>
<BsCircle className="w-4 h-4" />
</Button>
)
) : (
<Link
to={ROUTES.runs.detailWithSheet(
tenantId,
task.workflowRunId || task.id,
{
type: 'task-detail',
props: {
selectedWorkflowRunId: task.workflowRunId || task.id,
selectedTaskId: task.id,
},
},
)}
>
<Button
tooltip="Scope into child task"
variant="link"
size="icon"
className="group-hover:opacity-100 opacity-0 transition-opacity duration-200"
>
<BsArrowDownRightCircle className="w-4 h-4" />
</Button>
</Link>
)}
</div>
</foreignObject>
</g>
);
};
const renderTick = useCallback(
(props: { x: number; y: number; payload: { value: string } }) => {
const { x, y, payload } = props;
return (
<Tick
x={x}
y={y}
payload={payload}
workflowRunId={workflowRunId}
selectedTaskId={selectedTaskId}
handleBarClick={handleBarClick}
toggleTask={toggleTask}
processedData={processedData}
tenantId={tenantId}
/>
);
},
[workflowRunId, selectedTaskId, handleBarClick, toggleTask, processedData],
);
// Handle loading or error states
if (
isLoading ||
isError ||
!processedData.data ||
processedData.data.length === 0
!isLoading &&
(isError || !processedData.data || processedData.data.length === 0)
) {
return (
<>
<Skeleton className="h-[100px] w-full" />
</>
);
return null;
}
if (isLoading) {
return <Skeleton className="h-[100px] w-full" />;
}
// Compute dynamic chart height
@@ -756,21 +666,6 @@ export function Waterfall({
},
};
// Handler for bar click events
const handleBarClick = (data: any) => {
if (data && data.id) {
// Handle task selection for sidebar
if (handleTaskSelect) {
handleTaskSelect(data.id, data.workflowRunId);
}
// Handle expansion if the task has children
if (data.hasChildren) {
openTask(data.id, data.depth);
}
}
};
return (
<ChartContainer
config={chartConfig}
@@ -873,3 +768,139 @@ export function Waterfall({
</ChartContainer>
);
}
const Tick = ({
x,
y,
payload,
workflowRunId,
selectedTaskId,
handleBarClick,
toggleTask,
processedData,
tenantId,
}: {
x: number;
y: number;
payload: { value: string };
workflowRunId: string;
selectedTaskId?: string;
handleBarClick: (task: ProcessedTaskData) => void;
toggleTask: (taskId: string, hasChildren: boolean, taskDepth: number) => void;
processedData: ProcessedData;
tenantId: string;
}) => {
const task = processedData.data.find(
(t) => t.taskDisplayName === payload.value,
);
if (!task) {
// Return empty element instead of null
return <g transform={`translate(${x},${y})`}></g>;
}
const indentation = task.depth * 12; // 12px indentation per level
return (
<g transform={`translate(${x},${y})`}>
<foreignObject
x={-180} // Start position (right aligned)
y={-10} // Vertically center
width={180}
height={20}
>
<div
className={`group flex flex-row items-center pl-[${indentation}px] size-full`}
>
{/* Expand/collapse button */}
<div
data-haschildren={task.hasChildren}
className="data-[haschildren=true]:cursor-info flex flex-row items-center justify-between size-[20px] h-[20px] mr-[4px]"
onClick={() =>
task.hasChildren &&
toggleTask(task.id, task.hasChildren, task.depth)
}
>
{task.hasChildren &&
(task.isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
))}
</div>
{/* Task label */}
<div
className="cursor-pointer flex flex-row justify-between w-full grow text-left text-xs overflow-auto text-ellipsis whitespace-nowrap gap-2 items-center"
onClick={() => handleBarClick(task)}
>
<RunId
displayName={task.taskDisplayName}
id={task.id}
onClick={() => handleBarClick(task)}
className={task.id === selectedTaskId ? 'underline' : ''}
attempt={task.attempt}
/>
</div>
{workflowRunId === task.workflowRunId &&
task.taskExternalId === workflowRunId &&
task.parentId && (
<Link
to={ROUTES.runs.detailWithSheet(tenantId, task.parentId, {
type: 'task-detail',
props: {
selectedWorkflowRunId: task.workflowRunId,
selectedTaskId: task.id,
},
})}
onClick={(e) => e.stopPropagation()}
>
<Button
tooltip="Zoom out to parent task"
variant="link"
size="icon"
className="group-hover:opacity-100 opacity-0 transition-opacity duration-200"
>
<BsArrowUpLeftCircle className="w-4 h-4 transform" />
</Button>
</Link>
)}
{task.hasChildren && (
<Link
to={ROUTES.runs.detailWithSheet(
tenantId,
task.workflowRunId || task.id,
{
type: 'task-detail',
props: {
selectedWorkflowRunId: task.workflowRunId || task.id,
selectedTaskId: task.id,
},
},
)}
>
<Button
tooltip="Drill into child task"
variant="link"
size="icon"
className="group-hover:opacity-100 opacity-0 transition-opacity duration-200"
>
{' '}
<Drill className="w-4 h-4" />
</Button>
</Link>
)}
{task.queuedDuration === null && (
<TooltipProvider>
<BaseTooltip>
<TooltipTrigger>
<Loader className="animate-[spin_3s_linear_infinite] h-4" />
</TooltipTrigger>
<TooltipContent>This task has not started</TooltipContent>
</BaseTooltip>
</TooltipProvider>
)}
</div>
</foreignObject>
</g>
);
};

View File

@@ -80,7 +80,7 @@ function RunDetailSheetContent() {
return (
<>
<div className="h-full flex flex-col relative">
<div className="sticky top-0 z-10 bg-yellow-500 bg-slate-100 dark:bg-slate-900 px-4 pb-2">
<div className="sticky top-0 z-10 bg-slate-100 dark:bg-slate-900 px-4 pb-2">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col gap-2 mt-4">
<div className="flex flex-row items-center gap-2">
@@ -119,7 +119,7 @@ function RunDetailSheetContent() {
</div>
</div>
</div>
<div className="flex-1 overflow-y-scroll">
<div className="flex-1 overflow-y-clip">
<div className="bg-slate-100 dark:bg-slate-900">
<WorkflowRunVisualizer
workflowRunId={data?.run?.metadata.id || ''}

View File

@@ -20,7 +20,10 @@ import {
} from '@/next/components/ui/page-header';
import { Headline } from '@/next/components/ui/page-header';
import { Duration } from '@/next/components/ui/duration';
import { V1TaskStatus } from '@/lib/api/generated/data-contracts';
import {
V1TaskStatus,
V1WorkflowRun,
} from '@/lib/api/generated/data-contracts';
import { TriggerRunModal } from '@/next/components/runs/trigger-run-modal';
import {
Tabs,
@@ -62,8 +65,8 @@ function RunDetailPageContent({ workflowRunId }: RunDetailPageProps) {
const [showTriggerModal, setShowTriggerModal] = useState(false);
const workflow = useMemo(() => data?.run, [data]);
const tasks = useMemo(() => data?.tasks, [data]);
const workflow = data?.run;
const tasks = data?.tasks;
const { open: openSheet, sheet } = useSideSheet();
@@ -203,38 +206,11 @@ function RunDetailPageContent({ workflowRunId }: RunDetailPageProps) {
</div>
);
}
const Timing = () => {
const timings: JSX.Element[] = [
<span key="created" className="flex items-center gap-2">
<span>Created</span>
<RelativeDate date={workflow.createdAt} />
</span>,
<span key="started" className="flex items-center gap-2">
<span>Started</span>
<RelativeDate date={workflow.startedAt} />
</span>,
<span key="duration" className="flex items-center gap-2">
<span>Duration</span>
<span className="whitespace-nowrap">
<Duration
start={workflow.startedAt}
end={workflow.finishedAt}
status={workflow.status}
/>
</span>
</span>,
];
return (
<span className="flex flex-col items-start gap-y-2 text-sm text-muted-foreground">
{timings}
</span>
);
};
return (
<BasicLayout>
<Headline>
<PageTitle description={<Timing />}>
<PageTitle description={<Timing workflow={workflow} />}>
<div className="text-2xl font-bold truncate flex items-center gap-2">
<RunsBadge status={workflow.status} variant="xs" />
<RunId wfRun={workflow} />
@@ -368,3 +344,31 @@ function RunDetailPageContent({ workflowRunId }: RunDetailPageProps) {
</BasicLayout>
);
}
const Timing = ({ workflow }: { workflow: V1WorkflowRun }) => {
const timings: JSX.Element[] = [
<span key="created" className="flex items-center gap-2">
<span>Created</span>
<RelativeDate date={workflow.createdAt} />
</span>,
<span key="started" className="flex items-center gap-2">
<span>Started</span>
<RelativeDate date={workflow.startedAt} />
</span>,
<span key="duration" className="flex items-center gap-2">
<span>Duration</span>
<span className="whitespace-nowrap">
<Duration
start={workflow.startedAt}
end={workflow.finishedAt}
status={workflow.status}
/>
</span>
</span>,
];
return (
<span className="flex flex-col items-start gap-y-2 text-sm text-muted-foreground">
{timings}
</span>
);
};

View File

@@ -1392,6 +1392,8 @@ func (r *OLAPRepositoryImpl) GetTaskTimings(ctx context.Context, tenantId string
// start out by getting a list of task external ids for the workflow run id
rootTaskExternalIds := make([]pgtype.UUID, 0)
sevenDaysAgo := time.Now().Add(-time.Hour * 24 * 7)
minInsertedAt := time.Now()
rootTasks, err := r.queries.FlattenTasksByExternalIds(ctx, r.readPool, sqlcv1.FlattenTasksByExternalIdsParams{
Externalids: []pgtype.UUID{workflowRunId},
@@ -1404,12 +1406,24 @@ func (r *OLAPRepositoryImpl) GetTaskTimings(ctx context.Context, tenantId string
for _, task := range rootTasks {
rootTaskExternalIds = append(rootTaskExternalIds, task.ExternalID)
if task.InsertedAt.Time.Before(minInsertedAt) {
minInsertedAt = task.InsertedAt.Time
}
}
// Setting the maximum lookback period to 7 days
// to prevent scanning a zillion partitions on the tasks,
// runs, and dags tables.
if minInsertedAt.Before(sevenDaysAgo) {
minInsertedAt = sevenDaysAgo
}
runsList, err := r.queries.GetRunsListRecursive(ctx, r.readPool, sqlcv1.GetRunsListRecursiveParams{
Taskexternalids: rootTaskExternalIds,
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
Depth: depth,
Createdafter: sqlchelpers.TimestamptzFromTime(minInsertedAt),
})
if err != nil {

View File

@@ -1321,6 +1321,8 @@ WITH RECURSIVE all_runs AS (
WHERE
r.tenant_id = @tenantId::uuid
AND ar.depth < @depth::int
AND r.inserted_at >= @createdAfter::timestamptz
AND t.inserted_at >= @createdAfter::timestamptz
)
SELECT
tenant_id,

View File

@@ -317,6 +317,8 @@ WITH RECURSIVE all_runs AS (
WHERE
r.tenant_id = $1::uuid
AND ar.depth < $3::int
AND r.inserted_at >= $4::timestamptz
AND t.inserted_at >= $4::timestamptz
)
SELECT
tenant_id,
@@ -332,9 +334,10 @@ WHERE
`
type GetRunsListRecursiveParams struct {
Tenantid pgtype.UUID `json:"tenantid"`
Taskexternalids []pgtype.UUID `json:"taskexternalids"`
Depth int32 `json:"depth"`
Tenantid pgtype.UUID `json:"tenantid"`
Taskexternalids []pgtype.UUID `json:"taskexternalids"`
Depth int32 `json:"depth"`
Createdafter pgtype.Timestamptz `json:"createdafter"`
}
type GetRunsListRecursiveRow struct {
@@ -347,7 +350,12 @@ type GetRunsListRecursiveRow struct {
}
func (q *Queries) GetRunsListRecursive(ctx context.Context, db DBTX, arg GetRunsListRecursiveParams) ([]*GetRunsListRecursiveRow, error) {
rows, err := db.Query(ctx, getRunsListRecursive, arg.Tenantid, arg.Taskexternalids, arg.Depth)
rows, err := db.Query(ctx, getRunsListRecursive,
arg.Tenantid,
arg.Taskexternalids,
arg.Depth,
arg.Createdafter,
)
if err != nil {
return nil, err
}