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 158307fababa..91a5a6a3bed7 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 @@ -19,6 +19,7 @@ "axios": "^1.8.2", "lodash": "^4.17.21", "react": "^18.3.1", + "react-calendar-timeline": "^0.30.0-beta.3", "react-dom": "^18.3.1", "react-router-dom": "^7.3.0", "react-syntax-highlighter": "^15.6.1", @@ -1220,6 +1221,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@interactjs/types": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz", + "integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==", + "license": "MIT", + "peer": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3313,6 +3321,12 @@ "dev": true, "license": "MIT" }, + "node_modules/batch-processor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz", + "integrity": "sha512-xoLQD8gmmR32MeuBHgH0Tzd5PuSZx71ZsbhVxOCRbgktZEPe4SQy7s9Z50uPp0F/f7iw2XmkHN2xkgbMfckMDA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3516,6 +3530,12 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3888,6 +3908,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -4023,6 +4050,15 @@ "dev": true, "license": "ISC" }, + "node_modules/element-resize-detector": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz", + "integrity": "sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==", + "license": "MIT", + "dependencies": { + "batch-processor": "1.0.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5168,6 +5204,16 @@ "node": ">=0.8.19" } }, + "node_modules/interactjs": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz", + "integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@interactjs/types": "1.10.27" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5855,6 +5901,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6392,6 +6444,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-calendar-timeline": { + "version": "0.30.0-beta.3", + "resolved": "https://registry.npmjs.org/react-calendar-timeline/-/react-calendar-timeline-0.30.0-beta.3.tgz", + "integrity": "sha512-TckfoAzJvK5FEQo83vejbVtSDX1XNxcFmfCq92lMZKQiEuzbk7adcQi+ySlL9uSZ1ulFZS6YMAS6rzEGsVtZ2A==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "element-resize-detector": "^1.2.4", + "lodash": "^4.17.21", + "memoize-one": "^6.0.0" + }, + "peerDependencies": { + "dayjs": ">=1.10.0", + "interactjs": "1.10.27", + "react": "^18 || ^19.0.0-rc-66855b96-20241106", + "react-dom": "^18 || ^19.0.0-rc-66855b96-20241106" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.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 c3fb8a23b748..ad1a680b5cf3 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 @@ -27,6 +27,7 @@ "axios": "^1.8.2", "lodash": "^4.17.21", "react": "^18.3.1", + "react-calendar-timeline": "^0.30.0-beta.3", "react-dom": "^18.3.1", "react-router-dom": "^7.3.0", "react-syntax-highlighter": "^15.6.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 4f97360cedd4..75ca28d3d945 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 @@ -303,6 +303,10 @@ export interface QueryTask { totalCpuTime: string totalScheduledTime: string userMemoryReservation: string + firstStartTime: string + lastStartTime: string + lastEndTime: string + endTime: string } taskStatus: { nodeId: string 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 41883a90fcbe..2f24ddba3c85 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 @@ -13,13 +13,13 @@ */ import React, { ReactNode, useState } from 'react' import { useLocation, useParams } from 'react-router-dom' -import { Alert, Box, Divider, Grid2 as Grid, Tabs, Tab, Typography } from '@mui/material' +import { 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 { QueryStagePerformance } from './QueryStagePerformance' -import { Texts } from '../constant.ts' +import { QuerySplitsTimeline } from './QuerySplitsTimeline' const tabValues = ['overview', 'livePlan', 'stagePerformance', 'splits', 'json', 'references'] as const type TabValue = (typeof tabValues)[number] @@ -27,7 +27,7 @@ const tabComponentMap: Record = { overview: , livePlan: , stagePerformance: , - splits: {Texts.Error.NotImplemented}, + splits: , json: , references: , } @@ -61,7 +61,7 @@ export const QueryDetails = () => { - + diff --git a/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QuerySplitsTimeline.tsx b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QuerySplitsTimeline.tsx new file mode 100644 index 000000000000..b0fd56dcaacf --- /dev/null +++ b/core/trino-web-ui/src/main/resources/webapp-preview/src/components/QuerySplitsTimeline.tsx @@ -0,0 +1,345 @@ +/* + * 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, type ComponentProps, type HTMLAttributes, type Ref } from 'react' +import { Alert, Box, CircularProgress, Divider, Grid2 as Grid, Tooltip, Typography } from '@mui/material' +import { blue, green, purple, teal } from '@mui/material/colors' +import { darken, useTheme } from '@mui/material/styles' +import Timeline, { type TimelineGroupBase, type TimelineItemBase } from 'react-calendar-timeline' +import { queryStatusApi, QueryStage, QueryStatusInfo, QueryTask } from '../api/webapp/api.ts' +import { Texts } from '../constant.ts' +import { ApiResponse } from '../api/base.ts' +import { getTaskIdSuffix } from '../utils/utils' +import { QueryProgressBar } from './QueryProgressBar' +import 'react-calendar-timeline/dist/style.css' + +interface IQueryStatus { + info: QueryStatusInfo | null +} + +type SplitTimelineItem = TimelineItemBase & { + color: string + bgColor: string + borderColor: string +} + +type TimelineItemRenderer = NonNullable['itemRenderer']> +type TimelineItemRendererProps = Parameters[0] + +export const QuerySplitsTimeline = () => { + const { queryId } = useParams() + const theme = useTheme() + const initialQueryStatus: IQueryStatus = { + info: null, + } + + const [queryStatus, setQueryStatus] = useState(initialQueryStatus) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const queryStatusRef = useRef(queryStatus) + + useEffect(() => { + queryStatusRef.current = queryStatus + }, [queryStatus]) + + 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, + }) + setError(null) + } else { + setError(`${Texts.Error.Communication} ${apiResponse.status}: ${apiResponse.message}`) + } + }) + } + } + + const renderSplitsTimeline = (stages: QueryStage[]) => { + const tasks = stages + .flatMap((stage) => stage.tasks) + .map((task: QueryTask) => ({ + taskId: getTaskIdSuffix(task.taskStatus.taskId), + time: { + create: task.stats.createTime, + firstStart: task.stats.firstStartTime, + lastStart: task.stats.lastStartTime, + lastEnd: task.stats.lastEndTime, + end: task.stats.endTime, + }, + })) + + const groups: TimelineGroupBase[] = [] + const items: SplitTimelineItem[] = [] + const timelineColors = + theme.palette.mode === 'light' + ? { + created: teal[300], + firstSplitStarted: purple[300], + lastSplitStarted: blue[300], + lastSplitEnded: green[300], + } + : { + created: teal[700], + firstSplitStarted: purple[600], + lastSplitStarted: blue[600], + lastSplitEnded: green[600], + } + const legendEntries = [ + { key: 'created', label: 'Task created', color: timelineColors.created }, + { key: 'firstSplitStarted', label: 'First split started', color: timelineColors.firstSplitStarted }, + { key: 'lastSplitStarted', label: 'Last split started', color: timelineColors.lastSplitStarted }, + { key: 'lastSplitEnded', label: 'Last split ended', color: timelineColors.lastSplitEnded }, + ] + const applyColorProps = (background: string) => ({ + bgColor: background, + color: theme.palette.getContrastText(background), + borderColor: darken(background, 0.5), + }) + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] + const stageId = task.taskId.substring(0, task.taskId.indexOf('.')) + const taskNumber = getTaskIdSuffix(task.taskId) + + if (taskNumber === '0.0') { + groups.push({ + id: stageId, + title: `Stage ${stageId}`, + }) + } + items.push({ + id: `${task.taskId} - created`, + group: stageId, + title: `${task.taskId} - created`, + start_time: new Date(task.time.create).getTime(), + end_time: new Date(task.time.firstStart).getTime(), + ...applyColorProps(timelineColors.created), + }) + items.push({ + id: `${task.taskId} - first split started`, + group: stageId, + title: `${task.taskId} - first split started`, + start_time: new Date(task.time.firstStart).getTime(), + end_time: new Date(task.time.lastStart).getTime(), + ...applyColorProps(timelineColors.firstSplitStarted), + }) + items.push({ + id: `${task.taskId} - last split started`, + group: stageId, + title: `${task.taskId} - last split started`, + start_time: new Date(task.time.lastStart).getTime(), + end_time: new Date(task.time.lastEnd).getTime(), + ...applyColorProps(timelineColors.lastSplitStarted), + }) + items.push({ + id: `${task.taskId} - last split ended`, + group: stageId, + title: `${task.taskId} - last split ended`, + start_time: new Date(task.time.lastEnd).getTime(), + end_time: new Date(task.time.end).getTime(), + ...applyColorProps(timelineColors.lastSplitEnded), + }) + } + + if (items.length === 0) { + return No split timeline data available. + } + + const timelineStartTime = items.reduce((min, item) => Math.min(min, item.start_time), items[0].start_time) + const timelineEndTime = items.reduce((max, item) => Math.max(max, item.end_time), items[0].end_time) + + const itemRenderer: TimelineItemRenderer = ({ item, itemContext, getItemProps }: TimelineItemRendererProps) => { + const splitItem = item as SplitTimelineItem + const itemProps = getItemProps({ + style: { + color: splitItem.color, + backgroundColor: splitItem.bgColor, + borderColor: splitItem.borderColor, + borderStyle: 'solid', + borderWidth: 1, + borderRadius: 4, + borderLeftWidth: 1, + borderRightWidth: 1, + }, + }) + const { key: itemKey, title, ref: itemRef, ...restItemProps } = itemProps + // Drop the react-calendar-timeline provided title to avoid default tooltips; MUI tooltip will handle hover info. + delete (restItemProps as { title?: string }).title + const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + const formattedStart = timeFormatter.format(new Date(splitItem.start_time)) + const formattedEnd = timeFormatter.format(new Date(splitItem.end_time)) + return ( + + + {title} + + + {`${formattedStart} – ${formattedEnd}`} + + + } + > + } + {...(restItemProps as HTMLAttributes)} + > + + {itemContext.title} + + + + ) + } + + return ( + + + {legendEntries.map((entry) => ( + + + + {entry.label} + + + ))} + + + + + ) + } + + return ( + <> + {loading && } + {error && {Texts.Error.QueryNotFound}} + + {!loading && !error && queryStatus.info && ( + + + + + + + + {queryStatus.info?.stages.stages ? ( + + + + Splits timeline + + + {renderSplitsTimeline(queryStatus.info.stages.stages)} + + + ) : ( + <> + + + Splits timeline will appear automatically when query starts running. + + + + )} + + + + )} + + ) +}