blob: bc3a425f283c7beca8b189dda3852b74b228daf4 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Adapted from apache/doris-website PR #4043, commit
// 133f948c235995a917b2e1f6d4e9d764b6d62726.
import {
Background,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type NodeMouseHandler,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Button, Checkbox, Descriptions, Input, Space, Tag } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DagUiState, ProfileDagNode, ProfileGraphIR } from './profileGraphTypes';
import { ProfileGraphEdge } from './ProfileGraphEdge';
import {
formatBytes,
formatCount,
formatDurationNs,
layoutProfileDag,
OPERATOR_NODE_HEIGHT,
OPERATOR_NODE_WIDTH,
selectSlowestOperators,
type ProfileFlowNode,
} from './profileGraphLayout';
import { ProfileGraphFragmentNode, ProfileGraphNode } from './ProfileGraphNode';
interface ProfileGraphProps {
state: DagUiState;
graph: ProfileGraphIR | null;
error: string | null;
}
const nodeTypes = { profileOperator: ProfileGraphNode, profileFragment: ProfileGraphFragmentNode };
const edgeTypes = { profileElk: ProfileGraphEdge };
function ProfileGraphLegend() {
return (
<aside className="profile-graph-legend" aria-label="Execution graph legend">
<span><i className="profile-graph-legend__line profile-graph-legend__line--data" />Data flow</span>
<span>
<i className="profile-graph-legend__line profile-graph-legend__line--dependency" />
Execution dependency (prerequisite dependent)
</span>
<span><i className="profile-graph-legend__heat" />Longer execution</span>
<span><i className="profile-graph-legend__wait" />Longer wait</span>
</aside>
);
}
function valueText(value: unknown): string | null {
if (typeof value === 'string' || typeof value === 'boolean') return String(value);
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (Array.isArray(value)) {
const parts = value.map(valueText).filter((value): value is string => value !== null);
return parts.length ? parts.join(', ') : null;
}
return null;
}
function OperatorDetails({ node }: { node: ProfileDagNode }) {
const items = [
['Type', node.operatorType],
['Role', node.role],
['Fragment', node.fragmentId.replace('fragment:', 'Fragment ')],
['Pipeline', node.pipelineId.split('/').at(-1)?.replace('pipeline:', 'Pipeline ') ?? node.pipelineId],
['Plan node ID', node.planNodeId],
['Nereids ID', node.nereidsId],
['Execution max', formatDurationNs(node.timing?.execTime?.maxNs)],
['Execution average', formatDurationNs(node.timing?.execTime?.avgNs)],
['Wait max', formatDurationNs(node.timing?.waitTime?.maxNs)],
['Input rows', formatCount(node.metrics?.inputRows?.max)],
['Output rows', formatCount(node.metrics?.outputRows?.max)],
['Memory peak', formatBytes(node.metrics?.memoryPeakBytes?.max)],
].filter(([, value]) => value !== null && value !== undefined);
return (
<>
<Descriptions bordered column={1} size="small" items={items.map(([label, children]) => ({ label, children }))} />
{Object.keys(node.planInfo).length > 0 && (
<section className="profile-plan-info">
<h3>Plan information</h3>
<Descriptions
bordered
column={1}
size="small"
items={Object.entries(node.planInfo).flatMap(([label, value]) => {
const text = valueText(value);
return text === null ? [] : [{ label, children: text }];
})}
/>
</section>
)}
</>
);
}
function visibleGraph(graph: ProfileGraphIR, collapsed: Set<string>): ProfileGraphIR {
if (collapsed.size === 0) return graph;
const nodes = graph.graph.nodes.filter((node) => !collapsed.has(node.fragmentId));
const nodeIds = new Set(nodes.map((node) => node.id));
return {
...graph,
graph: { ...graph.graph, nodes, edges: graph.graph.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) },
fragments: graph.fragments.filter((fragment) => !collapsed.has(fragment.id)),
pipelines: graph.pipelines.filter((pipeline) => !collapsed.has(pipeline.fragmentId)),
};
}
function ProfileGraphCanvas({ graph }: { graph: ProfileGraphIR }) {
const [nodes, setNodes] = useState<ProfileFlowNode[]>([]);
const [edges, setEdges] = useState<Awaited<ReturnType<typeof layoutProfileDag>>['edges']>([]);
const [selected, setSelected] = useState<ProfileDagNode | null>(null);
const [hotspotsVisible, setHotspotsVisible] = useState(true);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [layoutError, setLayoutError] = useState<string | null>(null);
const canvas = useRef<HTMLDivElement>(null);
const { fitView, getInternalNode, setCenter } = useReactFlow<ProfileFlowNode>();
const filtered = useMemo(() => visibleGraph(graph, collapsed), [collapsed, graph]);
useEffect(() => {
let cancelled = false;
void layoutProfileDag(filtered).then((layout) => {
if (cancelled) return;
setNodes(layout.nodes);
setEdges(layout.edges);
setLayoutError(null);
requestAnimationFrame(() => void fitView({ padding: 0.12 }));
}).catch(() => {
if (!cancelled) {
setNodes([]);
setEdges([]);
setLayoutError('The execution graph could not be laid out.');
}
});
return () => { cancelled = true; };
}, [filtered, fitView]);
const focusNode = useCallback((nodeId: string) => {
const internal = getInternalNode(nodeId);
if (!internal) return;
const { x, y } = internal.internals.positionAbsolute;
const width = internal.measured.width ?? OPERATOR_NODE_WIDTH;
const height = internal.measured.height ?? OPERATOR_NODE_HEIGHT;
setNodes((current) => current.map((node) => ({ ...node, selected: node.id === nodeId })));
void setCenter(x + width / 2, y + height / 2, { zoom: 1, duration: 400 });
}, [getInternalNode, setCenter]);
const matches = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
if (!needle) return [];
return graph.graph.nodes.filter((node) => [
node.label,
node.operatorType,
node.planNodeId,
node.nereidsId,
node.planInfo.table,
].some((value) => String(value ?? '').toLocaleLowerCase().includes(needle)));
}, [graph.graph.nodes, search]);
const hotspots = useMemo(() => selectSlowestOperators(graph), [graph]);
const operatorsById = useMemo(
() => new Map(graph.graph.nodes.map((node) => [node.id, node])),
[graph.graph.nodes],
);
const onNodeClick = useMemo<NodeMouseHandler<ProfileFlowNode>>(() => (_event, flowNode) => {
if (flowNode.data.kind !== 'operator') return;
setSelected(flowNode.data.node);
focusNode(flowNode.id);
}, [focusNode]);
const reset = () => {
setSearch('');
setCollapsed(new Set());
setSelected(null);
setNodes((current) => current.map((node) => ({ ...node, selected: false })));
requestAnimationFrame(() => void fitView({ padding: 0.12, duration: 300 }));
};
return (
<div className="profile-graph-workspace">
<div className="profile-graph-toolbar">
<Input.Search
allowClear
aria-label="Search graph operators"
placeholder="Search operator, table, or plan ID"
value={search}
onChange={(event) => setSearch(event.target.value)}
onSearch={() => { if (matches[0]) focusNode(matches[0].id); }}
/>
<Button onClick={() => void fitView({ padding: 0.12, duration: 300 })}>Fit</Button>
<Button onClick={reset}>Reset</Button>
</div>
{search.trim() && (
<div className="profile-graph-results" aria-live="polite">
<strong>{matches.length} matches</strong>
{matches.slice(0, 12).map((node) => (
<Button key={node.id} size="small" onClick={() => focusNode(node.id)}>{node.label}</Button>
))}
</div>
)}
<div className="profile-fragment-controls" aria-label="Fragment visibility">
{graph.fragments.map((fragment) => (
<Checkbox
key={fragment.id}
checked={!collapsed.has(fragment.id)}
onChange={(event) => setCollapsed((current) => {
const next = new Set(current);
if (event.target.checked) next.delete(fragment.id); else next.add(fragment.id);
return next;
})}
>Fragment {fragment.number}</Checkbox>
))}
</div>
<div className="profile-graph-content">
<div ref={canvas} className="profile-graph-canvas" aria-label="Profile execution graph">
{layoutError ? <div className="profile-graph-state" role="alert">{layoutError}</div> : nodes.length === 0
? <div className="profile-graph-state">Laying out the execution graph…</div>
: (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
nodesConnectable={false}
edgesReconnectable={false}
deleteKeyCode={null}
minZoom={0.08}
maxZoom={1.8}
>
<Background gap={24} size={1} />
<MiniMap pannable zoomable aria-label="Execution graph overview" />
<Controls showInteractive={false} />
</ReactFlow>
)}
{hotspotsVisible ? (
<aside className="profile-floating-panel profile-hotspots" aria-label="Slowest operators">
<div className="profile-floating-panel__heading">
<p className="ui-label">Slowest by exec max</p>
<Button type="text" size="small" onClick={() => setHotspotsVisible(false)}>Hide</Button>
</div>
{hotspots.length === 0 ? <p className="profile-floating-panel__empty">No execution timing available.</p> : hotspots.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => {
setSelected(operatorsById.get(item.id) ?? null);
focusNode(item.id);
}}
>
<span className="profile-hotspots__rank">{index + 1}</span>
<strong>{item.label}</strong>
<small className="profile-hotspots__duration">{formatDurationNs(item.execMaxNs)}</small>
<small className="profile-hotspots__id">{item.id}</small>
</button>
))}
</aside>
) : (
<Button className="profile-floating-panel-show profile-floating-panel-show--left" onClick={() => setHotspotsVisible(true)}>
Show slowest
</Button>
)}
{selected && (
<aside className="profile-floating-panel profile-operator-details" aria-label="Operator details">
<div className="profile-floating-panel__heading">
<div>
<p className="ui-label">Operator details</p>
<h3>{selected.label}</h3>
<p className="profile-operator-details__id">{selected.id}</p>
</div>
<Button type="text" size="small" onClick={() => setSelected(null)}>Hide</Button>
</div>
<OperatorDetails node={selected} />
</aside>
)}
</div>
</div>
</div>
);
}
export function ProfileGraph({ state, graph, error }: ProfileGraphProps) {
if (state !== 'ready' || graph === null) {
const message = error ?? (state === 'parsing'
? 'Parsing the execution graph…'
: 'Select Visual to build the execution graph.');
return <div className="profile-graph-state" role={state === 'failed' || state === 'unavailable' ? 'alert' : 'status'}>{message}</div>;
}
const notices = graph.warnings.length + graph.unresolvedReferences.length;
return (
<section className="profile-graph" aria-label="Execution graph">
<Space wrap className="profile-graph-summary">
<Tag>{graph.summary.fragmentCount} fragments</Tag>
<Tag>{graph.summary.pipelineCount} pipelines</Tag>
<Tag>{graph.summary.nodeCount} operators</Tag>
<Tag>{graph.summary.edgeCount} connections</Tag>
{notices > 0 && <Tag color="warning">{notices} parsing notices</Tag>}
</Space>
<ProfileGraphLegend />
<ReactFlowProvider><ProfileGraphCanvas graph={graph} /></ReactFlowProvider>
</section>
);
}