From c00cd0681a4f1b6f62bc217e47906c7d444f4346 Mon Sep 17 00:00:00 2001 From: Peter Kosztolanyi Date: Tue, 12 Aug 2025 23:00:17 +0200 Subject: [PATCH] Add query live plan flow to Preview Web UI --- .../webapp-preview/package-lock.json | 228 ++++++++++++++++-- .../resources/webapp-preview/package.json | 2 + .../webapp-preview/src/api/webapp/api.ts | 41 ++++ .../src/components/QueryDetails.tsx | 5 +- .../src/components/QueryLivePlan.tsx | 152 ++++++++++++ .../src/components/flow/HelpMessage.tsx | 69 ++++++ .../src/components/flow/OperatorNode.tsx | 90 +++++++ .../src/components/flow/PlanFragmentNode.tsx | 192 +++++++++++++++ .../components/flow/RemoteExchangeNode.tsx | 37 +++ .../src/components/flow/flowUtils.ts | 155 ++++++++++++ .../src/components/flow/layout.ts | 99 ++++++++ .../src/components/flow/types.ts | 45 ++++ .../webapp-preview/src/utils/utils.ts | 8 + 13 files changed, 1106 insertions(+), 17 deletions(-) create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryLivePlan.tsx create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/HelpMessage.tsx create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/OperatorNode.tsx create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/PlanFragmentNode.tsx create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/RemoteExchangeNode.tsx create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/flowUtils.ts create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/layout.ts create mode 100644 core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/types.ts diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/package-lock.json b/core/trino-web-ui/src/main/resources/webapp-preview/package-lock.json index 1c24737712aa..b18cab53ee08 100644 --- a/core/trino-web-ui/src/main/resources/webapp-preview/package-lock.json +++ b/core/trino-web-ui/src/main/resources/webapp-preview/package-lock.json @@ -8,12 +8,14 @@ "name": "webapp-preview", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^1.1.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.2.5", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@mui/x-charts": "^7.27.1", + "@xyflow/react": "^12.8.2", "axios": "^1.8.2", "lodash": "^4.17.21", "react": "^18.3.1", @@ -369,6 +371,24 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.5.tgz", + "integrity": "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -2525,6 +2545,15 @@ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2549,6 +2578,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", @@ -2564,6 +2599,25 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2936,6 +2990,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.2.tgz", + "integrity": "sha512-VifLpxOy74ck283NQOtBn1e8igmB7xo7ADDKxyBHkKd8IKpyr16TgaYOhzqVwNMdB4NT+m++zfkic530L+gEXw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.66", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.66", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.66.tgz", + "integrity": "sha512-TTxESDwPsATnuDMUeYYtKe4wt9v8bRO29dgYBhR8HyhSCzipnAdIL/1CDfFd+WqS1srVreo24u6zZeVIDk4r3Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -3397,6 +3511,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3549,6 +3669,37 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -3595,6 +3746,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3631,6 +3791,50 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7138,6 +7342,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -7372,21 +7585,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/package.json b/core/trino-web-ui/src/main/resources/webapp-preview/package.json index b1bd7b6e1230..c3fb8a23b748 100644 --- a/core/trino-web-ui/src/main/resources/webapp-preview/package.json +++ b/core/trino-web-ui/src/main/resources/webapp-preview/package.json @@ -16,12 +16,14 @@ "check:clean": "npm clean-install && npm run lint && npm run prettier:check" }, "dependencies": { + "@dagrejs/dagre": "^1.1.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.2.5", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@mui/x-charts": "^7.27.1", + "@xyflow/react": "^12.8.2", "axios": "^1.8.2", "lodash": "^4.17.21", "react": "^18.3.1", diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/api/webapp/api.ts b/core/trino-web-ui/src/main/resources/webapp-preview/src/api/webapp/api.ts index 3870bda8ad21..e5f6a2fa315c 100644 --- a/core/trino-web-ui/src/main/resources/webapp-preview/src/api/webapp/api.ts +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/api/webapp/api.ts @@ -217,6 +217,46 @@ export interface QueryRoutine { authorization: string } +export interface QueryStagePlan { + id: string + jsonRepresentation: string + root: { + id: string + } +} + +export interface QueryStageStats { + completedDrivers: number + fullyBlocked: boolean + totalCpuTime: string + totalScheduledTime: string + userMemoryReservation: string + queuedDrivers: number + runningDrivers: number + blockedDrivers: number + runningTasks: number + completedTasks: number + totalTasks: number + processedInputDataSize: string + processedInputPositions: number + bufferedDataSize: string + outputDataSize: string + outputPositions: number +} + +export interface QueryStage { + coordinatorOnly: boolean + plan: QueryStagePlan + stageId: string + state: string + stageStats: QueryStageStats +} + +export interface QueryStages { + outputStageId: string + stages: QueryStage[] +} + export interface QueryStatusInfo extends QueryInfoBase { session: Session query: string @@ -227,6 +267,7 @@ export interface QueryStatusInfo extends QueryInfoBase { finalQueryInfo: boolean referencedTables: QueryTable[] routines: QueryRoutine[] + stages: QueryStages } export async function statsApi(): Promise> { diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryDetails.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryDetails.tsx index f30612251d2a..69c94e91ba51 100644 --- a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryDetails.tsx +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryDetails.tsx @@ -16,6 +16,7 @@ import { useLocation, useParams } from 'react-router-dom' import { Alert, Box, Divider, Grid2 as Grid, Tabs, Tab, Typography } from '@mui/material' import { QueryJson } from './QueryJson' import { QueryReferences } from './QueryReferences' +import { QueryLivePlan } from './QueryLivePlan' import { QueryOverview } from './QueryOverview' import { Texts } from '../constant.ts' @@ -23,7 +24,7 @@ const tabValues = ['overview', 'livePlan', 'stagePerformance', 'splits', 'json', type TabValue = (typeof tabValues)[number] const tabComponentMap: Record = { overview: , - livePlan: {Texts.Error.NotImplemented}, + livePlan: , stagePerformance: {Texts.Error.NotImplemented}, splits: {Texts.Error.NotImplemented}, json: , @@ -57,7 +58,7 @@ export const QueryDetails = () => { - + diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryLivePlan.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryLivePlan.tsx new file mode 100644 index 000000000000..2b079a748172 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QueryLivePlan.tsx @@ -0,0 +1,152 @@ +/* + * Licensed 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. + */ +import { useParams } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Alert, Box, CircularProgress, Grid2 as Grid } from '@mui/material' +import { ReactFlow, type Edge, type Node, useNodesState, useEdgesState } from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { queryStatusApi, QueryStatusInfo } from '../api/webapp/api.ts' +import { QueryProgressBar } from './QueryProgressBar' +import { nodeTypes, getLayoutedElements } from './flow/layout' +import { HelpMessage } from './flow/HelpMessage' +import { getFlowElements } from './flow/flowUtils' +import { IQueryStatus, LayoutDirectionType } from './flow/types' +import { ApiResponse } from '../api/base.ts' +import { Texts } from '../constant.ts' + +export const QueryLivePlan = () => { + const { queryId } = useParams() + const initialQueryStatus: IQueryStatus = { + info: null, + ended: false, + } + + const [queryStatus, setQueryStatus] = useState(initialQueryStatus) + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const [layoutDirection, setLayoutDirection] = useState('BT') + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const queryStatusRef = useRef(queryStatus) + const containerRef = useRef(null) + + useEffect(() => { + queryStatusRef.current = queryStatus + }, [queryStatus]) + + useEffect(() => { + if (queryStatus.info?.stages) { + const flowElements = getFlowElements(queryStatus.info.stages, layoutDirection) + const layoutedElements = getLayoutedElements(flowElements.nodes, flowElements.edges, { + direction: layoutDirection, + }) + + setNodes(layoutedElements.nodes) + setEdges(layoutedElements.edges) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryStatus, layoutDirection]) + + useEffect(() => { + const runLoop = () => { + const queryEnded = !!queryStatusRef.current.info?.finalQueryInfo + if (!queryEnded) { + getQueryStatus() + setTimeout(runLoop, 3000) + } + } + + if (queryId) { + queryStatusRef.current = initialQueryStatus + } + + runLoop() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryId]) + + const getQueryStatus = () => { + if (queryId) { + queryStatusApi(queryId).then((apiResponse: ApiResponse) => { + setLoading(false) + if (apiResponse.status === 200 && apiResponse.data) { + setQueryStatus({ + info: apiResponse.data, + ended: apiResponse.data.finalQueryInfo, + }) + + setError(null) + } else { + setError(`${Texts.Error.Communication} ${apiResponse.status}: ${apiResponse.message}`) + } + }) + } + } + + return ( + <> + {loading && } + {error && {Texts.Error.QueryNotFound}} + + {!loading && !error && queryStatus.info && ( + + + + + + + + {queryStatus.info?.stages ? ( + + + + + + + + + + + + ) : ( + <> + + + Live plan will appear automatically when query starts running. + + + + )} + + + + )} + + ) +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/HelpMessage.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/HelpMessage.tsx new file mode 100644 index 000000000000..80b2db2a74d8 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/HelpMessage.tsx @@ -0,0 +1,69 @@ +/* + * Licensed 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. + */ +import { Box, Typography, ToggleButtonGroup, ToggleButton } from '@mui/material' +import React from 'react' +import { LayoutDirectionType } from './types' + +interface IHelpMessageProps { + layoutDirection: LayoutDirectionType + onLayoutDirectionChange: (layoutDirection: LayoutDirectionType) => void +} + +export const HelpMessage = ({ layoutDirection, onLayoutDirectionChange }: IHelpMessageProps) => { + const handleLayoutChange = (_event: React.MouseEvent, newDirection: LayoutDirectionType | null) => { + if (newDirection !== null) { + onLayoutDirectionChange(newDirection) + } + } + + return ( + + + Scroll to zoom in/out + + + + + Vertical + + + Horizontal + + + + ) +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/OperatorNode.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/OperatorNode.tsx new file mode 100644 index 000000000000..c8404880af0b --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/OperatorNode.tsx @@ -0,0 +1,90 @@ +/* + * Licensed 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. + */ +import { Box, Card, CardContent, Grid2 as Grid, Tooltip, Typography } from '@mui/material' +import { Handle, Position } from '@xyflow/react' +import { OPERATOR_NODE_WIDTH } from './layout' +import { truncateString } from '../../utils/utils.ts' + +export interface IOperatorNodeProps { + data: { + label: string + descriptor: Map + } +} + +/** + * Represents individual execution operators within a query plan fragment (e.g., LocalMerge, PartialSort, Aggregate). + * These are the building blocks that form subflows inside each PlanFragmentNode, displaying specific operations + * performed during query execution. + * + * Features: + * - Displays the operator name (label) and operation parameters (descriptor) + * - Shows truncated descriptor on the card with full details in tooltip + * - Positioned as child nodes within PlanFragmentNode containers + * - Connected via visible edges for sub flow connectivity + */ +export const OperatorNode = (props: IOperatorNodeProps) => { + const { label, descriptor } = props.data + + const _descriptor = + '(' + + Object.entries(descriptor) + .map(([key, value]) => key + ' = ' + String(value)) + .join(', ') + + ')' + + return ( + + + + + + {label} + + + {_descriptor} + + + } + > + + + + {label} + + + + + {truncateString(_descriptor, 35)} + + + + + + + + + + + ) +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/PlanFragmentNode.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/PlanFragmentNode.tsx new file mode 100644 index 000000000000..7e58524a86c2 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/PlanFragmentNode.tsx @@ -0,0 +1,192 @@ +/* + * Licensed 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. + */ +import { Box, Card, CardContent, CardHeader, Divider, Grid2 as Grid, Typography, Tooltip } from '@mui/material' +import Chip, { ChipProps } from '@mui/material/Chip' +import { Handle, Position } from '@xyflow/react' +import { STAGE_NODE_PADDING_TOP, STAGE_NODE_WIDTH, OPERATOR_NODE_HEIGHT } from './layout' +import { QueryStageStats } from '../../api/webapp/api.ts' +import { formatRows, parseAndFormatDataSize } from '../../utils/utils.ts' +import { LayoutDirectionType } from './types.ts' + +export interface IPlanFragmentNodeProps { + data: { + label: string + nrOfNodes: number + state: string + stats: QueryStageStats + layoutDirection: LayoutDirectionType + } +} + +/** + * Main container nodes in the query execution plan flow, each representing a complete plan stage. + * PlanFragmentNodes serve as the primary organizational units that contain multiple OperatorNodes + * and display comprehensive execution statistics and status information for the entire stage. + * + * Features: + * - Contains and organizes multiple OperatorNode components within its boundaries + * - Displays stage execution metrics (CPU time, memory, blocked time, buffered data, splits) + * - Shows real-time status with color-coded state chips (QUEUED, RUNNING, FINISHED, FAILED) + * - Provides input/output data statistics for the stage + */ +export const PlanFragmentNode = (props: IPlanFragmentNodeProps) => { + const { label, nrOfNodes, stats, state, layoutDirection } = props.data + const STATE_COLOR_MAP: Record = { + QUEUED: 'default', + RUNNING: 'info', + PLANNING: 'info', + FINISHED: 'success', + FAILED: 'error', + } + + const getStateColor = (state: string): ChipProps['color'] => { + switch (state) { + case 'QUEUED': + return STATE_COLOR_MAP.QUEUED + case 'PLANNING': + return STATE_COLOR_MAP.PLANNING + case 'STARTING': + case 'FINISHING': + case 'RUNNING': + return STATE_COLOR_MAP.RUNNING + case 'FAILED': + return STATE_COLOR_MAP.FAILED + case 'FINISHED': + return STATE_COLOR_MAP.FINISHED + default: + return STATE_COLOR_MAP.QUEUED + } + } + + return ( + + + + {label} + + } + action={} + sx={{ pb: 1, mr: 1 }} + /> + + + + + CPU Time + + + + + {stats.totalCpuTime} + + + + + Scheduled Time + + + + + {stats.totalScheduledTime} + + + + + Memory + + + + + {parseAndFormatDataSize(stats.userMemoryReservation)} + + + + + Buffered Data + + + + + {parseAndFormatDataSize(stats.bufferedDataSize)} + + + + + + Drivers (Q / R / F / B) + + + + + + {`${stats.queuedDrivers} / ${stats.runningDrivers} / ${stats.completedDrivers} / ${stats.blockedDrivers}`} + + + + + + Tasks (R / F / T) + + + + + + {`${stats.runningTasks} / ${stats.completedTasks} / ${stats.totalTasks}`} + + + + + + + + + Input + + + + + {parseAndFormatDataSize(stats.processedInputDataSize)} /{' '} + {formatRows(stats.processedInputPositions)} + + + + + + + + + + ) +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/RemoteExchangeNode.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/RemoteExchangeNode.tsx new file mode 100644 index 000000000000..0dd2c86f1bad --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/RemoteExchangeNode.tsx @@ -0,0 +1,37 @@ +/* + * Licensed 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. + */ +import { Box, Divider } from '@mui/material' +import { Handle, Position } from '@xyflow/react' +import { REMOTE_EXCHANGE_NODE_HEIGHT, OPERATOR_NODE_WIDTH } from './layout' + +/** + * Represents a specific operator node that takes input from another downstream fragment in the query execution flow. + * This node type visualizes cross-fragment data dependencies where one fragment needs to merge or consume + * data produced by a different stage in the query plan. + * + * Features: + * - Renders as a minimal divider line to show data flow connection points + * - Positioned as the last item in the stages to indicate remote data input + * - Uses distinguished (dotted) edges for flow connectivity + * - Minimal height design to emphasize its role as a connection rather than processing step + */ +export const RemoteExchangeNode = () => { + return ( + + + + + + ) +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/flowUtils.ts b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/flowUtils.ts new file mode 100644 index 000000000000..380d1280eea1 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/flowUtils.ts @@ -0,0 +1,155 @@ +/* + * Licensed 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. + */ +import { MarkerType, type Edge, type Node } from '@xyflow/react' +import { QueryStage, QueryStages, QueryStagePlan } from '../../api/webapp/api' +import { formatRows, parseAndFormatDataSize } from '../../utils/utils' +import { IFlowElements, IPlanFragmentNodeInfo, IPlanNodeProps, LayoutDirectionType } from './types' + +export const parseRemoteSources = (sourceFragmentIds: string | undefined): string[] => { + if (!sourceFragmentIds || sourceFragmentIds.trim() === '[]') { + return [] + } + return sourceFragmentIds.replace('[', '').replace(']', '').split(', ') +} + +export const createEdge = ( + source: string, + target: string, + options: { + isAnimated?: boolean + hasArrow?: boolean + label?: string + remoteEdge?: { targetStageId: string } + } = {} +): Edge => ({ + id: `${source}-${target}`, + source, + target, + markerEnd: options.hasArrow ? { type: MarkerType.ArrowClosed } : undefined, + style: { strokeWidth: 3 }, + animated: options.isAnimated, + label: options.label, + labelStyle: { fontSize: 16, fontWeight: 'bold' }, + data: { remoteEdge: options.remoteEdge }, +}) + +export const createPlanFragmentNode = ( + key: string, + stageNodeInfo: IPlanFragmentNodeInfo, + layoutDirection: LayoutDirectionType +): Node => ({ + id: `stage-${key}`, + type: 'planFragmentNode', + position: { x: 0, y: 0 }, + data: { + label: `Stage ${key}`, + nrOfNodes: stageNodeInfo.nodes.size, + state: stageNodeInfo.state, + stats: stageNodeInfo.stageStats, + layoutDirection, + }, +}) + +export const createChildNode = (stageId: string, key: string, node: IPlanNodeProps, index: number): Node => { + const remoteSources = parseRemoteSources(node.descriptor?.['sourceFragmentIds']) + return { + id: `node-${key}`, + type: remoteSources.length === 0 ? 'operatorNode' : 'remoteExchangeNode', + position: { x: 0, y: 0 }, + draggable: false, + data: { + index, + label: node.name, + descriptor: node.descriptor, + }, + parentId: stageId, + extent: 'parent', + } +} + +export const flattenNode = ( + rootNodeInfo: QueryStagePlan['root'], + node: IPlanNodeProps, + result: Map +) => { + result.set(node.id, { + id: node.id, + name: node.name, + descriptor: node.descriptor, + details: node.details, + sources: node.children.map((child: IPlanNodeProps) => child.id), + children: node.children, + }) + if (node.children) { + node.children.forEach((child: IPlanNodeProps) => flattenNode(rootNodeInfo, child, result)) + } +} + +export const getPlanFragmentsNodeInfo = (queryStages: QueryStages): Map => { + const planFragments: Map = new Map() + + queryStages.stages.forEach((queryStage: QueryStage) => { + const nodes: Map = new Map() + flattenNode(queryStage.plan.root, JSON.parse(queryStage.plan.jsonRepresentation), nodes) + + planFragments.set(queryStage.plan.id, { + stageId: queryStage.stageId, + id: queryStage.plan.id, + root: queryStage.plan.root.id, + stageStats: queryStage.stageStats, + state: queryStage.state, + nodes: nodes, + }) + }) + + return planFragments +} + +export const getFlowElements = (queryStages: QueryStages, layoutDirection: LayoutDirectionType): IFlowElements => { + const stages: Map = getPlanFragmentsNodeInfo(queryStages) + + const nodes: Node[] = Array.from(stages).flatMap(([key, planFragmentNodeInfo]) => { + const stageId: string = `stage-${key}` + const stageNode: Node = createPlanFragmentNode(key, planFragmentNodeInfo, layoutDirection) + const childNodes: Node[] = Array.from(planFragmentNodeInfo.nodes).map(([childKey, node], childIndex) => + createChildNode(stageId, childKey, node, childIndex) + ) + return [stageNode, ...childNodes] + }) + + const edges: Edge[] = Array.from(stages).flatMap(([key, planFragmentNodeInfo]) => { + const stageId: string = `stage-${key}` + return Array.from(planFragmentNodeInfo.nodes).flatMap(([nodeKey, node]) => { + const targetNodeId: string = `node-${nodeKey}` + + const sourceEdges: Edge[] = Array.from(node.sources || []).map((sourceKey) => + createEdge(`node-${sourceKey}`, targetNodeId, { hasArrow: true }) + ) + + const remoteSources = parseRemoteSources(node.descriptor?.['sourceFragmentIds']) + const remoteSourceEdges: Edge[] = remoteSources.map((sourceKey) => + createEdge(`stage-${sourceKey}`, targetNodeId, { + isAnimated: true, + hasArrow: false, + label: `${parseAndFormatDataSize(planFragmentNodeInfo.stageStats.outputDataSize)} / ${formatRows(planFragmentNodeInfo.stageStats.outputPositions)}`, + remoteEdge: { targetStageId: stageId }, + }) + ) + + return [...sourceEdges, ...remoteSourceEdges] + }) + }) + + return { nodes, edges } +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/layout.ts b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/layout.ts new file mode 100644 index 000000000000..abb540224435 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/layout.ts @@ -0,0 +1,99 @@ +/* + * Licensed 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. + */ +import Dagre from '@dagrejs/dagre' +import { type Edge, type Node } from '@xyflow/react' +import { RemoteExchangeNode } from './RemoteExchangeNode.tsx' +import { PlanFragmentNode } from './PlanFragmentNode.tsx' +import { OperatorNode } from './OperatorNode.tsx' + +export const STAGE_NODE_WIDTH = 400 +export const STAGE_NODE_PADDING_TOP = 280 +export const OPERATOR_NODE_HEIGHT = 90 +export const OPERATOR_NODE_WIDTH = 340 +export const OPERATOR_NODE_PADDING_LEFT = 30 +export const REMOTE_EXCHANGE_NODE_HEIGHT = 4 + +/** + * Node type definitions for the query execution plan flow visualization + * Each node type represents a different component in the query execution hierarchy + */ +export const nodeTypes = { + // PlanFragmentNode: Main container nodes in the flow, each representing a complete plan stage + // Contains multiple OperatorNodes and organizes the operators within a stage + planFragmentNode: PlanFragmentNode, + + // OperatorNode: Individual operator node within a stage (LocalMerge, PartialSort, Aggregate, etc.) + // Building blocks that form subflows inside each StageNode, representing specific operations + operatorNode: OperatorNode, + + // RemoteExchangeNode: Represents a specific operator that takes input from another downstream stage + // Appears as a thin divider line connecting data flow between different stages + remoteExchangeNode: RemoteExchangeNode, +} + +export const getLayoutedElements = (nodes: Node[], edges: Edge[], options: { direction: string }) => { + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})) + g.setGraph({ rankdir: options.direction }) + + // Only layout stage nodes - operator nodes are positioned relative to their parents + nodes + .filter((node) => node.type === 'planFragmentNode') + .forEach((node) => { + const { nrOfNodes } = node.data as { nrOfNodes: number } + g.setNode(node.id, { + width: STAGE_NODE_WIDTH, + height: STAGE_NODE_PADDING_TOP + nrOfNodes * OPERATOR_NODE_HEIGHT, + }) + }) + + // Only consider edges between stages for layout + edges + .filter((edge) => edge.data?.remoteEdge) + .forEach((edge) => { + const { targetStageId } = edge.data!.remoteEdge as { targetStageId: string } + g.setEdge(edge.source, targetStageId) + }) + + Dagre.layout(g) + + return { + nodes: nodes.map((node) => { + const layoutedNode = g.node(node.id) + if (layoutedNode) { + // Stage node - use dagre position + const { nrOfNodes } = node.data as { nrOfNodes: number } + const width = STAGE_NODE_WIDTH + const height = STAGE_NODE_PADDING_TOP + nrOfNodes * OPERATOR_NODE_HEIGHT + return { + ...node, + position: { + x: layoutedNode.x - width / 2, + y: layoutedNode.y - height / 2, + }, + } + } else { + // Operator node - position relative to parent + const { index } = node.data as { index: number } + return { + ...node, + position: { + x: OPERATOR_NODE_PADDING_LEFT, + y: STAGE_NODE_PADDING_TOP + index * OPERATOR_NODE_HEIGHT, + }, + } + } + }), + edges, + } +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/types.ts b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/types.ts new file mode 100644 index 000000000000..1db0dece5ae6 --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/flow/types.ts @@ -0,0 +1,45 @@ +/* + * Licensed 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. + */ +import { type Edge, type Node } from '@xyflow/react' +import { QueryStageStats, QueryStatusInfo } from '../../api/webapp/api' + +export type LayoutDirectionType = 'BT' | 'RL' + +export interface IQueryStatus { + info: QueryStatusInfo | null + ended: boolean +} + +export interface IFlowElements { + nodes: Node[] + edges: Edge[] +} + +export interface IPlanNodeProps { + id: string + name: string + descriptor: Record + details: string[] + sources: string[] + children: IPlanNodeProps[] +} + +export interface IPlanFragmentNodeInfo { + stageId: string + id: string + root: string + stageStats: QueryStageStats + state: string + nodes: Map +} diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/utils/utils.ts b/core/trino-web-ui/src/main/resources/webapp-preview/src/utils/utils.ts index 8d6609907477..3926aeaae5f9 100644 --- a/core/trino-web-ui/src/main/resources/webapp-preview/src/utils/utils.ts +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/utils/utils.ts @@ -122,6 +122,14 @@ export function precisionRound(n: number | null): string { return Math.round(n).toString() } +export function formatRows(count: number): string { + if (count === 1) { + return '1 row' + } + + return formatCount(count) + ' rows' +} + export function formatCount(count: number | null): string { if (count === null) { return ''