diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index ed9409b30..174d7d0bf 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -37,6 +37,7 @@ "@gravity-ui/date-utils": "^2.5.3", "@gravity-ui/dialog-fields": "^5.0.9", "@gravity-ui/eslint-config": "^3.2.0", + "@gravity-ui/graph": "^0.1.2", "@gravity-ui/icons": "^2.0.0", "@gravity-ui/navigation": "^2.15.3", "@gravity-ui/prettier-config": "^1.1.0", @@ -80,6 +81,7 @@ "d3-scale": "4", "d3-selection": "3", "d3-shape": "3", + "elkjs": "^0.9.3", "eslint": "^8.48.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-lodash": "^7.4.0", @@ -128,7 +130,8 @@ "typescript": "^5.2.2", "vis-data": "^7.1.7", "vis-network": "^9.1.2", - "wc-react": "^0.5.1" + "wc-react": "^0.5.1", + "web-worker": "^1.3.0" }, "engines": { "node": ">=18" @@ -4590,6 +4593,41 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@gravity-ui/graph": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-0.1.2.tgz", + "integrity": "sha512-SQPbRDtI1n3N2DAR46UI6VkefobO0D1cCvRI53n6Lh4cfNpL1ySJevAuYGcRZycpwfEySpPvpPshejXvHmZwrg==", + "dev": true, + "dependencies": { + "@preact/signals-core": "^1.5.1", + "intersects": "^2.7.2", + "lodash-es": "^4.17.21", + "rbush": "^3.0.1" + }, + "engines": { + "pnpm": "Please use npm instead of pnpm to install dependencies", + "yarn": "Please use npm instead of yarn to install dependencies" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@gravity-ui/graph/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "dev": true + }, + "node_modules/@gravity-ui/graph/node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "dev": true, + "dependencies": { + "quickselect": "^2.0.0" + } + }, "node_modules/@gravity-ui/i18n": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@gravity-ui/i18n/-/i18n-1.5.1.tgz", @@ -5841,6 +5879,16 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@preact/signals-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", + "integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@reach/observe-rect": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz", @@ -8124,9 +8172,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.197", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", - "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", "dev": true }, "node_modules/@types/luxon": { @@ -12225,6 +12273,12 @@ "integrity": "sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ==", "dev": true }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "dev": true + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -14795,6 +14849,12 @@ "node": ">=12" } }, + "node_modules/intersects": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/intersects/-/intersects-2.7.2.tgz", + "integrity": "sha512-/LtLDq40iFtvnjhouev9p2R+jP+raVONPiD1t8Mcj879pkrLiav99BTRPBkfMPwSYr5vTNws3USGoW+8usS45A==", + "dev": true + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -17541,6 +17601,12 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "dev": true + }, "node_modules/lodash.debounce": { "version": "4.0.8", "dev": true, @@ -24073,9 +24139,9 @@ } }, "node_modules/vis-util": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.6.tgz", - "integrity": "sha512-HyAZ+x3q6/Xh6OFPIQOGPOPgnHL9KVNvWW6+k3aTCbAG8Tq7vncgRat6oY8ywZ7j1Fq0mOfUb6fLrFvfI9nUKg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.7.tgz", + "integrity": "sha512-E3L03G3+trvc/X4LXvBfih3YIHcKS2WrP0XTdZefr6W6Qi/2nNCqZfe4JFfJU6DcQLm6Gxqj2Pfl+02859oL5A==", "dev": true, "peer": true, "engines": { @@ -24087,7 +24153,7 @@ }, "peerDependencies": { "@egjs/hammerjs": "^2.0.0", - "component-emitter": "^1.3.0" + "component-emitter": "^1.3.0 || ^2.0.0" } }, "node_modules/walker": { @@ -24134,6 +24200,12 @@ "integrity": "sha512-AI5mFXbyUhAhzjBr12xpPAUDthE+qOWTGuRgOpj7a9qjO7+7q/A/IAS23lz5vmltFaKY+MWIPYopJfAoP5/E+Q==", "dev": true }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==", + "dev": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/packages/ui/package.json b/packages/ui/package.json index 274bb7057..f0c98ae0e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -92,6 +92,7 @@ "@gravity-ui/date-utils": "^2.5.3", "@gravity-ui/dialog-fields": "^5.0.9", "@gravity-ui/eslint-config": "^3.2.0", + "@gravity-ui/graph": "^0.1.2", "@gravity-ui/icons": "^2.0.0", "@gravity-ui/navigation": "^2.15.3", "@gravity-ui/prettier-config": "^1.1.0", @@ -135,6 +136,7 @@ "d3-scale": "4", "d3-selection": "3", "d3-shape": "3", + "elkjs": "^0.9.3", "eslint": "^8.48.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-lodash": "^7.4.0", @@ -183,7 +185,8 @@ "typescript": "^5.2.2", "vis-data": "^7.1.7", "vis-network": "^9.1.2", - "wc-react": "^0.5.1" + "wc-react": "^0.5.1", + "web-worker": "^1.3.0" }, "engines": { "node": ">=18" diff --git a/packages/ui/src/shared/constants/settings.ts b/packages/ui/src/shared/constants/settings.ts index a6f430c7f..7f8e3d5b6 100644 --- a/packages/ui/src/shared/constants/settings.ts +++ b/packages/ui/src/shared/constants/settings.ts @@ -113,6 +113,7 @@ export const SettingName = { STAGE: 'queryTrackerStage', YQL_AGENT_STAGE: 'yqlAgentStage', QUERIES_LIST_SIDEBAR_VISIBILITY_MODE: 'queriesListSidebarVisibilityMode', + NEW_GRAPH_TYPE: 'queryTrackerNewGraphType', }, CHYT: { LIST_COLUMNS: 'list_columns', diff --git a/packages/ui/src/ui/containers/SettingsPanel/settings-description.tsx b/packages/ui/src/ui/containers/SettingsPanel/settings-description.tsx index 587643475..b1d696d85 100644 --- a/packages/ui/src/ui/containers/SettingsPanel/settings-description.tsx +++ b/packages/ui/src/ui/containers/SettingsPanel/settings-description.tsx @@ -194,6 +194,16 @@ function useSettings(cluster: string, isAdmin: boolean): Array { settingNS={NAMESPACES.QUERY_TRACKER} />, ), + makeItem( + 'Query tracker graph', + 'top', + , + ), ]), ), diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.scss new file mode 100644 index 000000000..b3130ea4d --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.scss @@ -0,0 +1,51 @@ +.yt-detail-block { + display: grid; + grid-template-rows: 52px 1fr; + color: var(--g-color-text-primary); + padding: 10px 12px; + min-height: 70px; + min-width: 240px; + box-sizing: border-box; + background: var(--g-popup-background-color, var(--g-color-base-float)); + border: 1px solid var(--g-popup-border-color, var(--g-color-line-generic-solid)); + border-radius: 8px; + box-shadow: 0 1px 5px 0 #00000026; + + &__header { + display: flex; + align-items: center; + gap: 0 8px; + font-weight: 500; + } + + &__content { + margin-top: 8px; + } + + &__details { + font-size: 11px; + height: auto; + padding-bottom: 0; + overflow-y: auto; + + &_without-padding { + padding-top: 0; + } + + .node-details-info{ + grid-gap: 10px; + } + + .data-table__row { + height: auto; + } + + .g-text_variant_body-1 { + font-size: 11px; + } + } + + .graph-block-wrapper{ + border: none; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.tsx new file mode 100644 index 000000000..fc67878a0 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlock.tsx @@ -0,0 +1,60 @@ +import React, {CSSProperties, FC, useMemo, useRef} from 'react'; +import './DetailBlock.scss'; +import cn from 'bem-cn-lite'; +import {NodeBlock} from '../canvas/NodeBlock'; +import OperationNodeInfo from '../../OperationNodeInfo'; +import {hasDetailsInfo, hasJobsInfo, hasStagesInfo} from '../../utils'; +import {DetailBlockHeader} from './DetailBlockHeader'; +import {Graph} from '@gravity-ui/graph'; + +const b = cn('yt-detail-block'); + +type Props = { + block: NodeBlock; + graph: Graph; + showHeader?: boolean; + showInfo?: boolean; + className?: string; + style?: CSSProperties; +}; + +export const DetailBlock: FC = ({block, showHeader, showInfo, className, style}) => { + const detailBlockRef = useRef(null); + const {state} = block; + const {nodeProgress, details, schemas} = state.meta; + + const showNodeInfo = useMemo(() => { + return ( + showInfo && + (schemas || + hasStagesInfo(nodeProgress) || + hasJobsInfo(nodeProgress) || + hasDetailsInfo(details)) + ); + }, [details, nodeProgress, schemas, showInfo]); + + if (!showNodeInfo && !showHeader) return null; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {showHeader && } + {showNodeInfo && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockHeader.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockHeader.tsx new file mode 100644 index 000000000..347f586d2 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockHeader.tsx @@ -0,0 +1,40 @@ +import React, {FC, useMemo} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import {OperationContent} from './OperationContent'; +import {NodeTBlock} from '../canvas/NodeBlock'; +import {GraphBlockType} from '../enums'; +import {DetailBlockTitle} from './DetailBlockTitle'; + +type Props = { + block: NodeTBlock; +}; + +export const DetailBlockHeader: FC = ({ + block: { + meta: {icon, bottomText, nodeProgress}, + name, + is, + }, +}) => { + const isTable = is === GraphBlockType.Table; + + const showContent = useMemo(() => { + if (isTable) return Boolean(bottomText); + return nodeProgress && nodeProgress.total; + }, [bottomText, isTable, nodeProgress]); + + return ( + + + {showContent && ( + <> + {isTable ? ( + {bottomText || ''} + ) : ( + + )} + + )} + + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.scss new file mode 100644 index 000000000..66b0a473b --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.scss @@ -0,0 +1,16 @@ +.yt-detailed-block-title { + display: flex; + align-items: center; + gap: 0 8px; + + &__icon { + height: 16px; + width: 16px; + color: #657B8F; + } + + &__name { + font-size: 11px; + font-weight: bold; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.tsx new file mode 100644 index 000000000..a6d00b98c --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/DetailBlockTitle.tsx @@ -0,0 +1,20 @@ +import React, {FC} from 'react'; +import {NodeBlockMeta} from '../canvas/NodeBlock'; +import cn from 'bem-cn-lite'; +import './DetailBlockTitle.scss'; + +const block = cn('yt-detailed-block-title'); + +type Props = { + icon: NodeBlockMeta['icon']; + name: string; +}; + +export const DetailBlockTitle: FC = ({icon, name}) => { + return ( +
+ +
{name}
+
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.scss new file mode 100644 index 000000000..05985c03a --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.scss @@ -0,0 +1,11 @@ +.operation-job-item { + display: flex; + align-items: center; + gap: 0 4px; + + &__block { + height: 10px; + width: 10px; + border-radius: 2px; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.tsx new file mode 100644 index 000000000..80012061b --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/JobItem.tsx @@ -0,0 +1,24 @@ +import React, {FC} from 'react'; +import './JobItem.scss'; +import cn from 'bem-cn-lite'; +import {Tooltip} from '@gravity-ui/uikit'; + +const block = cn('operation-job-item'); + +type Props = { + color: string; + tooltip: string; + value?: number; +}; + +export const JobItem: FC = ({color, tooltip, value}) => { + if (!value) return null; + + return ( + +
+
{value} +
+ + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.scss new file mode 100644 index 000000000..6fd6ae0c3 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.scss @@ -0,0 +1,17 @@ +.graph-operation-content { + display: flex; + flex-direction: column; + gap: 6px 0; + font-size: 11px; + + &__info { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__jobs { + display: flex; + gap: 0 8px; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.tsx new file mode 100644 index 000000000..6117401d3 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/OperationContent.tsx @@ -0,0 +1,51 @@ +import React, {FC} from 'react'; +import {NodeProgress} from '../../models/plan'; +import './OperationContent.scss'; +import {JobItem} from './JobItem'; +import cn from 'bem-cn-lite'; +import {Progress, Text} from '@gravity-ui/uikit'; +import {makeJobsScope} from '../helpers/makeJobsScope'; +import {JOBS_COLOR_MAP} from '../constants'; + +const block = cn('graph-operation-content'); + +type Props = { + nodeProgress?: NodeProgress; +}; + +export const OperationContent: FC = ({nodeProgress}) => { + if (!nodeProgress || !nodeProgress.total) return null; + const total = nodeProgress.total + (nodeProgress.aborted || 0); + const pending = (nodeProgress.pending || 0) + (nodeProgress.running || 0); + + return ( +
+
+ +
+
+
+ {total} job(s) +
+
+ + + + +
+
+
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/index.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/index.ts new file mode 100644 index 000000000..c565f12f6 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/DetailBlock/index.ts @@ -0,0 +1 @@ +export {DetailBlock} from './DetailBlock'; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.scss new file mode 100644 index 000000000..b01b1f81d --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.scss @@ -0,0 +1,5 @@ +.yt-graph { + canvas.layer:nth-child(-n+2) { + background: var(--main-background); + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.tsx new file mode 100644 index 000000000..593caa402 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/Graph.tsx @@ -0,0 +1,24 @@ +import React, {FC} from 'react'; +import {GraphCanvas, Graph as GraphProps} from '@gravity-ui/graph'; +import './Graph.scss'; +import cn from 'bem-cn-lite'; + +const b = cn('yt-graph'); + +type Props = { + graphEditor: GraphProps; +}; + +export const Graph: FC = ({graphEditor}) => { + if (!graphEditor) return null; + + return ( + { + return <>; + }} + className={b()} + /> + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/GraphWrap.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/GraphWrap.tsx new file mode 100644 index 000000000..a06bd7ae3 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/GraphWrap.tsx @@ -0,0 +1,108 @@ +import React, {FC, useEffect, useMemo, useState} from 'react'; +import {Graph} from './Graph'; +import { + ECameraScaleLevel, + GraphState, + HookGraphParams, + useGraph, + useGraphEvent, +} from '@gravity-ui/graph'; +import {Loader} from '@gravity-ui/uikit'; +import {NodeBlock} from './canvas/NodeBlock'; +import {ProcessedGraph} from '../utils'; +import {PopupPortal} from './PopupLayer'; +import {createBlocks} from './helpers/createBlocks'; +import {useResultProgress} from '../PlanContext'; +import {GraphBlockType} from './enums'; +import {ELKConnection} from './connections/ELKConnection'; +import {getCSSPropertyValue, getHEXColor} from '../styles'; +import {calculatePositions} from './helpers/calculatePositions'; + +const getGraphConfig = (): HookGraphParams => { + const getColor = (name: string) => { + return getHEXColor( + getCSSPropertyValue(`--yql-graph-color-${name}`, document.body ?? undefined), + ); + }; + + return { + settings: { + connection: ELKConnection, + canDuplicateBlocks: false, + canCreateNewConnections: false, + canZoomCamera: true, + blockComponents: { + [GraphBlockType.Table]: NodeBlock, + [GraphBlockType.Operation]: NodeBlock, + }, + }, + viewConfiguration: { + colors: { + connection: { + background: getColor('edge'), + selectedBackground: getColor('edge-highlight'), + }, + block: { + background: '#fff', + selectedBorder: getColor('edge-highlight'), + border: getColor('edge'), + text: getColor('text-label'), + }, + }, + }, + }; +}; + +const BLOCK_SIDE = 100; +const blockSize = {height: BLOCK_SIDE, width: BLOCK_SIDE}; + +type Props = { + processedGraph: ProcessedGraph; +}; + +export const GraphWrap: FC = ({processedGraph}) => { + const {graph, setEntities, start} = useGraph(getGraphConfig()); + const [loading, setLoading] = useState(true); + const [scale, setScale] = useState(ECameraScaleLevel.Schematic); + const progress = useResultProgress(); + + useGraphEvent(graph, 'camera-change', (data) => { + const cameraScale = graph.cameraService.getCameraBlockScaleLevel(data.scale); + setScale( + cameraScale === ECameraScaleLevel.Detailed ? ECameraScaleLevel.Schematic : cameraScale, + ); + }); + + useGraphEvent(graph, 'state-change', ({state}) => { + if (state === GraphState.ATTACHED) { + start(); + } + }); + + const positions = useMemo(() => { + return calculatePositions({ + graph: processedGraph, + sizes: blockSize, + }); + }, [processedGraph]); + + useEffect(() => { + positions.then((data) => { + setLoading(false); + createBlocks(processedGraph, progress, data, scale, blockSize).then( + ({blocks, connections}) => { + setEntities({blocks, connections}); + }, + ); + }); + }, [positions, processedGraph, progress, scale, setEntities]); + + if (loading) return ; + + return ( +
+ + +
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.scss new file mode 100644 index 000000000..1b7fb47a7 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.scss @@ -0,0 +1,5 @@ +.yt-graph-click-popup { + --top: var(--yt-graph-popup-geometry-y, 0); + --left: calc(var(--yt-graph-popup-geometry-x, 0) + var(--yt-graph-popup-geometry-width, 0)); + transform: translate3d(var(--left, 0), var(--top, 0), 0) scale(var(--scale)); +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.tsx new file mode 100644 index 000000000..72eb6c5bf --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/ClickPopup.tsx @@ -0,0 +1,53 @@ +import React, {FC, MouseEvent, useRef} from 'react'; +import {useClickBlock} from '../hooks/useClickBlock'; +import {Graph} from '@gravity-ui/graph'; +import cn from 'bem-cn-lite'; +import './Popup.scss'; +import './ClickPopup.scss'; +import {DetailBlock} from '../DetailBlock'; +import {usePopupPosition} from '../hooks/usePopupPosition'; + +type Props = { + graph: Graph; +}; + +const commonClass = cn('yt-graph-popup'); +const block = cn('yt-graph-click-popup'); + +export const ClickPopup: FC = ({graph}) => { + const containerRef = useRef(null); + const clickedBlock = useClickBlock(graph); + + const style = usePopupPosition({ + container: containerRef.current, + block: clickedBlock, + cameraState: graph.cameraService.getCameraState(), + varName: '--yt-graph-popup-geometry', + }); + + const stopPropagation = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const className = [ + commonClass({ + visible: Boolean(clickedBlock), + }), + block(), + ].join(' '); + + return ( +
+ {clickedBlock && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.scss new file mode 100644 index 000000000..616a90519 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.scss @@ -0,0 +1,5 @@ +.yt-graph-hover-popup { + --top: var(--yt-graph--hover-popup-geometry-y, 0); + --left: calc(var(--yt-graph--hover-popup-geometry-x, 0) + var(--yt-graph--hover-popup-geometry-width, 0)); + transform: translate3d(var(--left, 0), var(--top, 0), 0) scale(var(--scale)); +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.tsx new file mode 100644 index 000000000..7e150a8f3 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/HoverPopup.tsx @@ -0,0 +1,56 @@ +import React, {FC, MouseEvent, useRef} from 'react'; +import {Graph} from '@gravity-ui/graph'; +import {useHoverBlock} from '../hooks/useHoverBlock'; +import {useClickBlock} from '../hooks/useClickBlock'; +import './Popup.scss'; +import './HoverPopup.scss'; +import cn from 'bem-cn-lite'; +import {DetailBlock} from '../DetailBlock'; +import {usePopupPosition} from '../hooks/usePopupPosition'; + +type Props = { + graph: Graph; +}; + +const commonClass = cn('yt-graph-popup'); + +export const HoverPopup: FC = ({graph}) => { + const containerRef = useRef(null); + const clickedBlock = useClickBlock(graph); + const hoveredBlock = useHoverBlock(graph, containerRef.current); + + const stopPropagation = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const style = usePopupPosition({ + container: containerRef.current, + block: hoveredBlock, + cameraState: graph.cameraService.getCameraState(), + varName: '--yt-graph--hover-popup-geometry', + }); + + const visible = hoveredBlock && hoveredBlock.state.id !== clickedBlock?.state.id; + return ( +
+ {hoveredBlock && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/Popup.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/Popup.scss new file mode 100644 index 000000000..381d7804a --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/Popup.scss @@ -0,0 +1,21 @@ +.yt-graph-popup { + --scale: calc(1 / var(--nv-graph-scale)); + transform-origin: 0 0; + position: absolute; + pointer-events: all; + margin-left: 8px; + opacity: 0; + + &_visible { + opacity: 1; + } + + &__details { + height: auto; + } + + .yt-detail-block__icon { + max-height: 16px; + max-width: 16px; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupLayer.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupLayer.ts new file mode 100644 index 000000000..a9cefc3a1 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupLayer.ts @@ -0,0 +1,41 @@ +import {Layer, LayerContext, LayerProps} from '@gravity-ui/graph'; + +export type AreaLayerProps = LayerProps; +export type AreaLayerContext = LayerContext; +export class PopupLayer extends Layer { + constructor(props: AreaLayerProps) { + super({ + ...props, + html: { + zIndex: 6, + classNames: ['graph-popup-layer'], + }, + }); + + this.context.camera.on('update', this.updateHTMLCamera); + } + + updateHTMLCamera = () => { + const state = this.context.camera.getCameraState(); + this.getHTML().style.transform = `matrix(${state.scale}, 0, 0, ${state.scale}, ${state.x}, ${state.y})`; + this.getHTML().style.setProperty('--nv-graph-scale', `${state.scale}`); + }; + + protected afterInit() { + const html = this.getHTML(); + + html.style.position = 'absolute'; + html.style.top = '0'; + html.style.left = '0'; + html.style.isolation = 'isolate'; + html.style.transformOrigin = '0 0'; + html.style.transformStyle = 'preserve-3d'; + html.style.willChange = 'transform'; + html.style.pointerEvents = 'none'; + } + + protected unmount(): void { + super.unmount(); + this.context.camera.off('update', this.updateHTMLCamera); + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupPortal.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupPortal.tsx new file mode 100644 index 000000000..215377d14 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/PopupPortal.tsx @@ -0,0 +1,30 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {ClickPopup} from './ClickPopup'; +import {createPortal} from 'react-dom'; +import {Graph} from '@gravity-ui/graph'; +import {PopupLayer} from './PopupLayer'; +import {HoverPopup} from './HoverPopup'; + +export function PopupPortal({graph}: {graph?: Graph | null}) { + const [ready, setReady] = useState(false); + const layerRef = useRef(null); + + useEffect(() => { + if (!graph) { + return; + } + + layerRef.current = graph.addLayer(PopupLayer, {}); + setReady(true); + }, [graph]); + + return graph && ready && layerRef.current + ? createPortal( + <> + + + , + layerRef.current.getHTML(), + ) + : null; +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/index.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/index.ts new file mode 100644 index 000000000..3d339ca57 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/PopupLayer/index.ts @@ -0,0 +1 @@ +export {PopupPortal} from './PopupPortal'; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.scss new file mode 100644 index 000000000..59ee653ca --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.scss @@ -0,0 +1,27 @@ +.yt-graph-zoom { + position: absolute; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + height: 96px; + width: 32px; + overflow: hidden; + border-radius: 8px; + background: #fff; + color: #657B8F; + box-shadow: 0 2px 8px 0 #0000000D, 0 4px 24px 0 #0000000D; + + &__size { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + font-weight: 500; + font-size: 11px; + border: 1px solid var(--g-color-line-generic); + border-left: none; + border-right: none; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.tsx new file mode 100644 index 000000000..9050720ab --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/Zoom.tsx @@ -0,0 +1,76 @@ +import React, {FC, useCallback, useEffect, useState} from 'react'; +import {Graph} from '@gravity-ui/graph'; +import cn from 'bem-cn-lite'; +import './Zoom.scss'; +import {ZoomButton} from './ZoomButton'; +import MagnifierPlusIcon from '@gravity-ui/icons/svgs/magnifier-plus.svg'; +import MagnifierMinusIcon from '@gravity-ui/icons/svgs/magnifier-minus.svg'; +import {ScaleStep} from '../enums'; +import debounce_ from 'lodash/debounce'; + +const block = cn('yt-graph-zoom'); +const zoomMap = [ScaleStep.Detailed, ScaleStep.Schematic, ScaleStep.Minimalistic]; + +type Props = { + graph: Graph; + layerElement: HTMLElement; +}; + +export const Zoom: FC = ({graph, layerElement}) => { + const [scaleIndex, setScaleIndex] = useState(1); + + const handleZoom = useCallback( + (direction: 'in' | 'out') => { + const newIndex = direction === 'out' ? scaleIndex + 1 : scaleIndex - 1; + if (newIndex < 0 || newIndex >= zoomMap.length) return; + + const scale = zoomMap[newIndex]; + const cameraState = graph.cameraService.getCameraState(); + graph.cameraService.set({...cameraState, scale}); + setScaleIndex(newIndex); + }, + [graph.cameraService, scaleIndex], + ); + + const onZoom = useCallback( + (e: WheelEvent) => { + handleZoom(e.deltaY > 0 ? 'out' : 'in'); + e.preventDefault(); + }, + [handleZoom], + ); + + const debouncedZoom = useCallback( + (e: WheelEvent) => { + debounce_(onZoom, 200)(e); + e.preventDefault(); + }, + [onZoom], + ); + + useEffect(() => { + layerElement.addEventListener('wheel', debouncedZoom); + + return () => { + layerElement.removeEventListener('wheel', debouncedZoom); + }; + }, [debouncedZoom, layerElement]); + + return ( +
+ handleZoom('in')} + disabled={zoomMap[scaleIndex] === ScaleStep.Detailed} + /> +
+
1 : {scaleIndex + 1}
+
+ handleZoom('out')} + disabled={zoomMap[scaleIndex] === ScaleStep.Minimalistic} + /> +
+ ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.scss b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.scss new file mode 100644 index 000000000..7e2aad3b0 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.scss @@ -0,0 +1,12 @@ +.yt-zoom-button { + height: 32px; + color: #657B8F; + + .g-button__icon { + height: 32px; + } + + &:disabled { + color: var(--g-color-text-hint); + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.tsx new file mode 100644 index 000000000..09452c8cc --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomButton.tsx @@ -0,0 +1,27 @@ +import React, {FC} from 'react'; +import {Button, Icon, IconData} from '@gravity-ui/uikit'; +import './ZoomButton.scss'; +import cn from 'bem-cn-lite'; + +const block = cn('yt-zoom-button'); + +type ButtonProps = { + icon: IconData; + disabled: boolean; + onClick: () => void; +}; + +export const ZoomButton: FC = ({icon, disabled, onClick}) => { + return ( + + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomLayer.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomLayer.tsx new file mode 100644 index 000000000..8350df7bd --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/ZoomLayer.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import {Layer, LayerProps} from '@gravity-ui/graph'; +import {Zoom} from './Zoom'; + +export class ZoomLayer extends Layer { + protected reactRoot: ReactDOM.Root | undefined; + + constructor(props: LayerProps) { + super({ + html: { + zIndex: 5, + classNames: ['zoom-layer'], + }, + ...props, + }); + this.reactRoot = undefined; + } + + unmount() { + this.reactRoot?.unmount(); + super.unmount(); + } + + protected override afterInit() { + const container = this.getHTML(); + this.reactRoot = ReactDOM.createRoot(container); + this.reactRoot.render(); + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/index.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/index.ts new file mode 100644 index 000000000..d5dca2e7c --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/ZoomLayer/index.ts @@ -0,0 +1 @@ +export {ZoomLayer} from './ZoomLayer'; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/canvas/NodeBlock.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/canvas/NodeBlock.tsx new file mode 100644 index 000000000..826ae0e5e --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/canvas/NodeBlock.tsx @@ -0,0 +1,267 @@ +import {CanvasBlock, ECameraScaleLevel, TBlock} from '@gravity-ui/graph'; +import {type NodeDetails, NodeProgress} from '../../models/plan'; +import {GRAPH_COLORS} from '../constants'; +import {OperationSchemas} from '../../utils'; + +const DEFAULT_CONTENT_OFFSET = 10; + +export type NodeBlockMeta = { + level: ECameraScaleLevel; + icon: { + src: string; + height: number; + width: number; + }; + bottomText?: string; + textSize: number; + padding?: number; + nodeProgress?: NodeProgress; + schemas?: OperationSchemas; + details?: NodeDetails; +}; + +export type NodeTBlock = Omit & {meta: NodeBlockMeta}; + +type RoundedBlockProps = { + x: number; + y: number; + height: number; + width: number; + radius: number; + selected: boolean; + background: { + default: string; + selected: string; + active: string; + }; + borderWidth: number; + borderColor: { + default: string; + selected: string; + active: string; + }; + progressPercent: number; +}; + +const MAX_TEXT_LENGTH = 16; + +export class NodeBlock extends CanvasBlock { + icon: null | HTMLImageElement = null; + + override renderMinimalisticBlock() { + this.renderBlock('minimalistic'); + } + + override renderSchematicView() { + this.renderBlock('schematic'); + } + + override renderDetailedView() { + this.renderBlock('schematic'); + } + + getGeometry() { + return this.connectedState.$geometry.value; + } + + private getProgressPercent() { + const nodeProgress = this.state.meta.nodeProgress; + if (this.state.meta.nodeProgress?.state === 'Finished') return 1; + if (!nodeProgress || !nodeProgress.total) return 0; + + return (nodeProgress.completed || 0) / nodeProgress.total; + } + + private checkNodeProgress() { + const {nodeProgress} = this.state.meta; + return ( + nodeProgress && + nodeProgress.state && + ['Started', 'InProgress'].includes(nodeProgress.state) + ); + } + + private getFont() { + return `normal ${this.state.meta.textSize}px YS Text, Arial, sans-serif`; + } + + private drawRoundBlock({ + x, + y, + height, + width, + radius, + background, + borderColor, + borderWidth, + selected, + progressPercent, + }: RoundedBlockProps) { + const blockType = selected ? 'selected' : 'default'; + + const ctx = this.context.ctx; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.closePath(); + ctx.fillStyle = background[blockType]; + ctx.fill(); + + ctx.strokeStyle = this.checkNodeProgress() ? borderColor.active : borderColor[blockType]; + ctx.lineWidth = borderWidth; + ctx.stroke(); + + if (progressPercent) { + ctx.beginPath(); + ctx.roundRect(x, y, width * progressPercent, height, radius); + ctx.closePath(); + ctx.fillStyle = background.active; + ctx.fill(); + } + } + + private async drawIcon(blockWidth: number, startYPosition: number) { + const {src, height, width} = this.state.meta.icon; + const {x, y} = this.state; + const iconX = x + (blockWidth - width) / 2; + const iconY = y + startYPosition; + + if (this.icon) { + this.context.ctx.drawImage(this.icon, iconX, iconY, width, height); + } else { + try { + this.icon = await this.loadImage(src); + this.context.ctx.drawImage(this.icon, iconX, iconY, width, height); + } catch (error) { + console.error('Failed to load image:', error); + } + } + } + + private drawBottomText() { + if (!this.state.meta.bottomText) return; + + this.context.ctx.fillStyle = this.context.colors.block?.text || GRAPH_COLORS.text; + this.context.ctx.textAlign = 'center'; + this.renderText(this.state.meta.bottomText, this.context.ctx, { + rect: { + x: this.state.x - this.state.width / 2, + y: this.state.y + this.state.height + this.state.height / 5, + width: this.state.width * 2, + height: this.state.height - this.state.height / 5, + }, + renderParams: { + font: this.getFont(), + }, + }); + } + + private drawInnerText(yPosition: number) { + const {height, width, x, y} = this.state; + const padding = this.state.meta.padding || 0; + const iconHeight = this.state.meta.icon.height; + const name = + this.state.name.length > MAX_TEXT_LENGTH + ? this.state.name.slice(0, 13) + '...' + : this.state.name; + + this.context.ctx.fillStyle = GRAPH_COLORS.text; + this.context.ctx.textAlign = 'center'; + this.renderText(name, this.context.ctx, { + rect: { + x: x + padding, + y: y + yPosition, + width: width - padding * 2, + height: height - iconHeight - DEFAULT_CONTENT_OFFSET, + }, + renderParams: { + font: this.getFont(), + }, + }); + } + + private drawJobCounter() { + const nodeProgress = this.state.meta.nodeProgress; + if (!nodeProgress || !nodeProgress.total) return; + + const total = nodeProgress.total + (nodeProgress.aborted || 0); + const padding = 5; + const {x, y} = this.state; + + const textMetrics = this.context.ctx.measureText(total.toString()); + const blockWidth = textMetrics.width + padding * 2; + const blockHeight = 16; + const blockX = x - blockWidth / 2; + const blockY = y - blockHeight / 2; + const radius = 4; + + this.context.ctx.beginPath(); + this.context.ctx.roundRect(blockX, blockY, blockWidth, blockHeight, radius); + this.context.ctx.closePath(); + this.context.ctx.fillStyle = GRAPH_COLORS.jobBlockBackground; + this.context.ctx.fill(); + + this.context.ctx.fillStyle = GRAPH_COLORS.jobBlockColor; + this.context.ctx.textAlign = 'center'; + this.context.ctx.textBaseline = 'top'; + this.context.ctx.fillText(total.toString(), x, blockY + 3.5); + } + + private renderContent(showInnerText = true) { + const {height, width} = this.state; + const padding = this.state.meta.padding || 0; + const textSize = this.state.meta.textSize; + const iconHeight = this.state.meta.icon.height; + + let textHeight = 0; + if (showInnerText) { + const contentWidth = width - padding * 2; + const textWidth = this.context.ctx.measureText(this.state.name).width; + const numLines = Math.ceil(textWidth / contentWidth); + textHeight = numLines * textSize; + } + + const contentHeight = textHeight + DEFAULT_CONTENT_OFFSET + iconHeight; + const startYPosition = (height - contentHeight) / 2; + + this.drawIcon(width, startYPosition); + if (showInnerText) this.drawInnerText(startYPosition + DEFAULT_CONTENT_OFFSET + iconHeight); + this.drawBottomText(); + this.drawJobCounter(); + } + + private loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + } + + private renderBlock(mode: 'minimalistic' | 'schematic') { + const {height, width} = this.state; + const {x, y} = this.state; + + this.drawRoundBlock({ + x, + y, + width, + height, + radius: 8, + selected: this.state.selected, + background: { + default: this.context.colors.block?.background || GRAPH_COLORS.background, + selected: GRAPH_COLORS.selectedBackground, + active: GRAPH_COLORS.progressColor, + }, + borderWidth: 1, + borderColor: { + default: this.context.colors.block?.border || GRAPH_COLORS.border, + selected: this.context.colors.block?.selectedBorder || GRAPH_COLORS.selectedBorder, + active: GRAPH_COLORS.progressBorder, + }, + progressPercent: this.getProgressPercent(), + }); + this.renderContent(mode === 'schematic'); + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/connections/ELKConnection.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/connections/ELKConnection.ts new file mode 100644 index 000000000..c41e6c7ae --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/connections/ELKConnection.ts @@ -0,0 +1,85 @@ +import {ElkExtendedEdge} from 'elkjs'; + +import {Path2DRenderStyleResult} from '@gravity-ui/graph/build/components/canvas/connections/BatchPath2D'; +import {BlockConnection} from '@gravity-ui/graph/build/components/canvas/connections/BlockConnection'; +import {TConnection} from '@gravity-ui/graph/build/store/connection/ConnectionState'; +import {curvePolyline} from '@gravity-ui/graph/build/utils/shapes/curvePolyline'; +import {trangleArrowForVector} from '@gravity-ui/graph/build/utils/shapes/triangle'; + +export type TElkTConnection = TConnection & { + elk: ElkExtendedEdge; +}; + +export class ELKConnection extends BlockConnection { + protected points: {x: number; y: number}[] = []; + + createPath() { + const elk = this.connectedState.$state.value.elk; + if (!elk.sections || !this.points?.length) { + return super.createPath(); + } + return curvePolyline(this.points, 10); + } + + createArrowPath(): Path2D { + const [start, end] = this.points.slice(this.points.length - 2); + return trangleArrowForVector(start, end, 16, 10); + } + + styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult { + const color = this.state.selected + ? this.context.colors.connection?.selectedBackground + : this.context.colors.connection?.background; + + ctx.fillStyle = color || '#ccc'; + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = this.state.selected || this.state.hovered ? -1 : 1; + return {type: 'both'}; + } + + getPoints() { + return this.points || []; + } + + afterRender?(_: CanvasRenderingContext2D): void { + return; + } + + updatePoints(): void { + super.updatePoints(); + const elk = this.connectedState.$state.value.elk; + if (!elk || !elk.sections) { + return; + } + const section = elk.sections[0]; + + this.points = [ + section.startPoint, + ...(section.bendPoints?.map((point) => point) || []), + section.endPoint, + ]; + + return; + } + + getBBox() { + const elk = this.connectedState.$state.value.elk; + if (!elk || !elk.sections) { + return super.getBBox(); + } + + const x: number[] = []; + const y: number[] = []; + elk.sections.forEach((c) => { + x.push(c.startPoint.x); + y.push(c.startPoint.y); + c.bendPoints?.forEach((point) => { + x.push(point.x); + y.push(point.y); + }); + x.push(c.endPoint.x); + y.push(c.endPoint.y); + }); + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/constants/index.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/constants/index.ts new file mode 100644 index 000000000..f95ef7006 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/constants/index.ts @@ -0,0 +1,20 @@ +export const GRAPH_COLORS = { + text: '#000', + border: '#e5e5e5', + selectedBorder: '#cecece', + progressBorder: '#69AA699E', + background: '#fff', + selectedBackground: '#f2f2f2', + progressColor: '#E5F8E4', + aborted: '#ddd', + failed: '#f40505', + jobBlockBackground: '#4C4C4C', + jobBlockColor: '#fff', +}; + +export const JOBS_COLOR_MAP = { + completed: '#3BC935CC', + pending: '#4E79EB', + aborted: '#FFDB4D', + failed: '#EA0805', +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/enums/index.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/enums/index.ts new file mode 100644 index 000000000..82edae982 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/enums/index.ts @@ -0,0 +1,10 @@ +export const enum ScaleStep { + Minimalistic = 0, + Schematic = 0.225, + Detailed = 0.7, +} + +export const enum GraphBlockType { + Table = 'table', + Operation = 'operation', +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/calculatePositions.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/calculatePositions.ts new file mode 100644 index 000000000..b5b96cac2 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/calculatePositions.ts @@ -0,0 +1,51 @@ +import {ProcessedGraph} from '../../utils'; +import ELK from 'elkjs'; + +export const calculatePositions = async ({ + graph, + sizes, +}: { + graph: ProcessedGraph; + sizes: {height: number; width: number}; +}) => { + const offset = sizes.height.toString(); + const elk = new ELK(); + return await elk.layout( + { + id: 'root', + children: graph.nodes.map((node) => ({ + id: node.id, + width: sizes.width, + height: sizes.height, + ...(node.level === 1 + ? { + layoutOptions: { + 'elk.layered.layering.layerConstraint': '1', + }, + } + : {}), + })), + edges: graph.edges.map((edge, i) => ({ + id: 'e' + i, + sources: [edge.from], + targets: [edge.to], + })), + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'spacing.nodeNode': offset, // Горизонтальное расстояние между узлами + 'spacing.nodeNodeBetweenLayers': Math.round(sizes.height / 2).toString(), // Уменьшите расстояние между слоями + 'spacing.edgeNode': '25', // Math.round(sizes.block.height / 2).toString(), // Расстояние между узлами и ребрами + 'spacing.edgeEdge': '25', // Расстояние между ребрами + 'spacing.edgeEdgeBetweenLayers': '35', // Уменьшите расстояние между ребрами в слоях + 'spacing.edgeNodeBetweenLayers': '25', // Расстояние между узлами и ребрами в слоях + 'nodePlacement.strategy': 'TIGHT_TREE', // Измените стратегию размещения узлов + 'elk.layered.nodePlacement.bk.fixedAlignment': 'CENTER', //'BALANCED', // Выравнивание узлов + 'elk.layered.crossingMinimization.strategy': 'LAYER_PER_LAYER_SWEEP', + 'elk.layered.thoroughness': '200', // Увеличьте уровень детализации + 'elk.portConstraints': 'FREE', + }, + }, + {}, + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/createBlocks.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/createBlocks.tsx new file mode 100644 index 000000000..41773acf6 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/createBlocks.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import {ProcessedGraph, ProcessedNode} from '../../utils'; +import {ECameraScaleLevel, TBlockId} from '@gravity-ui/graph'; +import {NodeBlockMeta, NodeTBlock} from '../canvas/NodeBlock'; +import LayoutHeaderCellsLargeIcon from '@gravity-ui/icons/svgs/layout-header-cells-large.svg'; +import MoleculeIcon from '@gravity-ui/icons/svgs/molecule.svg'; +import type {Progress} from '../../models/plan'; +import {GraphBlockType} from '../enums'; +import {iconToBase} from './iconToBase'; +import {TElkTConnection} from '../connections/ELKConnection'; +import {ElkNode} from 'elkjs'; + +const getBlockConfig = ( + node: ProcessedNode, +): { + type: 'table' | 'operation'; + icon: NodeBlockMeta['icon']; + background: string; +} => { + switch (node.type) { + case 'in': + case 'out': + return { + type: GraphBlockType.Table, + icon: { + src: iconToBase(, '#657B8F'), + height: 16, + width: 16, + }, + background: '#fff', + }; + default: + return { + type: GraphBlockType.Operation, + icon: { + src: iconToBase(, '#657B8F'), + height: 16, + width: 16, + }, + background: '#fff', + }; + } +}; + +export const createBlocks = async ( + graph: ProcessedGraph, + progress: Progress | null, + positions: ElkNode, + level: ECameraScaleLevel, + sizes: {height: number; width: number}, +): Promise<{blocks: NodeTBlock[]; connections: TElkTConnection[]}> => { + const isMinimalisticView = level === ECameraScaleLevel.Minimalistic; + + if (!positions.children) return {blocks: [], connections: []}; + + const map = new Map(positions.children.map((i) => [i.id, i])); + const blocks = graph.nodes.map((node) => { + const {icon, type} = getBlockConfig(node); + + const isTable = type === 'table'; + let name = node.title || node.id; + let bottomText = isMinimalisticView ? name : undefined; + if (isTable) { + name = 'Table'; + bottomText = node.label; + } + + const {x, y} = map.get(node.id)!; + return { + x: x as number, + y: y as number, + width: sizes.width, + height: sizes.height, + id: node.id as TBlockId, + is: type, + selected: false, + name, + anchors: [], + meta: { + level, + icon: { + src: icon.src, + height: icon.height, + width: icon.width, + }, + bottomText, + textSize: 11, + padding: 10, + nodeProgress: progress ? progress[node.id] : undefined, + schemas: node.schemas, + details: node.details, + }, + }; + }); + + const connections = positions.edges?.reduce((acc, c) => { + acc.push({ + id: c.id as string, + sourceBlockId: c.sources[0] as string, + targetBlockId: c.targets[0] as string, + elk: c, + }); + + return acc; + }, []); + + return { + blocks, + connections: connections || [], + }; +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/iconToBase.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/iconToBase.ts new file mode 100644 index 000000000..973b4982e --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/iconToBase.ts @@ -0,0 +1,5 @@ +import {renderToString} from 'react-dom/server'; + +export const iconToBase = (icon: React.ReactElement, color: string) => + 'data:image/svg+xml;charset=utf-8,' + + encodeURIComponent(renderToString(icon).replace('currentColor', color)); diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/makeJobsScope.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/makeJobsScope.ts new file mode 100644 index 000000000..10ceda491 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/helpers/makeJobsScope.ts @@ -0,0 +1,21 @@ +import {NodeProgress} from '../../models/plan'; +import {Stack} from '@gravity-ui/uikit'; +import {JOBS_COLOR_MAP} from '../constants'; + +export const makeJobsScope = (total: number, progress: NodeProgress): Stack[] => { + if (!progress.total) return []; + const percent = total / 100; + + return (Object.keys(JOBS_COLOR_MAP) as Array).reduce( + (acc, key) => { + if (key in progress) { + let value = progress[key] || 0; + if (key === 'pending') value += progress.running || 0; + if (!value) return acc; + acc.push({color: JOBS_COLOR_MAP[key], value: value / percent}); + } + return acc; + }, + [], + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useBlockGeometry.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useBlockGeometry.ts new file mode 100644 index 000000000..34582b67f --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useBlockGeometry.ts @@ -0,0 +1,39 @@ +import {useCallback, useEffect, useState} from 'react'; +import {NodeBlock} from '../canvas/NodeBlock'; +import {TRect} from '@gravity-ui/graph'; + +export const useBlockGeometry = ({ + container, + block, + varName, +}: { + container?: HTMLDivElement | null; + block?: NodeBlock; + varName: string; +}) => { + const [geometry, setGeometry] = useState(); + const setSizes = useCallback( + (blockRect?: TRect) => { + setGeometry(blockRect); + container?.style.setProperty(`${varName}-x`, `${blockRect?.x || 0}px`); + container?.style.setProperty(`${varName}-y`, `${blockRect?.y || 0}px`); + container?.style.setProperty(`${varName}-width`, `${blockRect?.width || 0}px`); + container?.style.setProperty(`${varName}-height`, `${blockRect?.height || 0}px`); + }, + [container?.style, varName], + ); + + useEffect(() => { + if (!block) { + setSizes(undefined); + return () => {}; + } + + setSizes(block.connectedState.$geometry.value); + return block.connectedState.$geometry.subscribe((nextGeometry) => { + setSizes(nextGeometry); + }); + }, [block, setSizes, container]); + + return geometry; +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useClickBlock.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useClickBlock.ts new file mode 100644 index 000000000..01c5a9c51 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useClickBlock.ts @@ -0,0 +1,15 @@ +import {useEffect, useState} from 'react'; +import {Graph} from '@gravity-ui/graph'; +import {NodeBlock} from '../canvas/NodeBlock'; + +export function useClickBlock(graph: Graph) { + const [block, setBlock] = useState(undefined); + + useEffect(() => { + return graph.on('click', (event) => { + setBlock(event.detail.target instanceof NodeBlock ? event.detail.target : undefined); + }); + }, [graph, setBlock]); + + return block; +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useHoverBlock.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useHoverBlock.ts new file mode 100644 index 000000000..d71f09195 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/useHoverBlock.ts @@ -0,0 +1,59 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {Graph} from '@gravity-ui/graph'; +import {GraphMouseEvent} from '@gravity-ui/graph/build/graphEvents'; +import {NodeBlock} from '../canvas/NodeBlock'; + +const OPEN_TIMEOUT = 200; +const CLOSE_TIMEOUT = 300; + +export function useHoverBlock(graph: Graph, container: HTMLDivElement | null) { + const timerId = useRef(undefined); + const [block, setBlock] = useState(undefined); + + const handleOnGraphMouseEnter = useCallback(({detail}: GraphMouseEvent) => { + const targetBlock = + detail.target instanceof NodeBlock && !detail.target.state.selected + ? detail.target + : undefined; + + timerId.current = window.setTimeout( + () => { + setBlock(targetBlock as NodeBlock); + }, + targetBlock ? OPEN_TIMEOUT : CLOSE_TIMEOUT, + ); + }, []); + + const handleClearTimeout = useCallback(() => { + clearTimeout(timerId.current); + }, []); + + const handleClosePopup = useCallback(() => { + timerId.current = window.setTimeout(() => { + setBlock(undefined); + }, CLOSE_TIMEOUT); + }, []); + + useEffect(() => { + if (!container) return () => {}; + container.addEventListener('mouseenter', handleClearTimeout); + container.addEventListener('mouseleave', handleClosePopup); + + return () => { + container.removeEventListener('mouseenter', handleClearTimeout); + container.removeEventListener('mouseleave', handleClosePopup); + }; + }, [container, handleClearTimeout, handleClosePopup]); + + useEffect(() => { + graph.on('mouseenter', handleOnGraphMouseEnter); + graph.on('mouseleave', handleClearTimeout); + + return () => { + graph.off('mouseenter', handleOnGraphMouseEnter); + graph.off('mouseleave', handleClearTimeout); + }; + }, [graph, handleClearTimeout, handleOnGraphMouseEnter]); + + return block; +} diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/usePopupPosition.ts b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/usePopupPosition.ts new file mode 100644 index 000000000..a910ab4f4 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/Plan/GraphEditor/hooks/usePopupPosition.ts @@ -0,0 +1,55 @@ +import {CSSProperties, useEffect, useMemo, useState} from 'react'; +import {NodeBlock} from '../canvas/NodeBlock'; +import {useBlockGeometry} from './useBlockGeometry'; +import {TCameraState} from '@gravity-ui/graph/build/services/camera/CameraService'; + +type Props = (data: { + container?: HTMLDivElement | null; + block?: NodeBlock; + cameraState: TCameraState; + varName: string; +}) => CSSProperties; + +export const usePopupPosition: Props = ({container, block, cameraState, varName}) => { + const [popupRect, setPopupRect] = useState(); + + const geometry = useBlockGeometry({ + container, + block, + varName, + }); + + useEffect(() => { + if (container) { + setPopupRect(container.getBoundingClientRect()); + } + }, [container, geometry]); + + return useMemo(() => { + if (!block || !popupRect) return {}; + + const blockGeometry = block.getGeometry(); + const height = + popupRect.height > cameraState.height ? cameraState.height : popupRect.height; + const yDiff = + cameraState.relativeHeight - + (blockGeometry.y + cameraState.relativeY) - + height / cameraState.scale; + + const needHorizontalSwitch = + cameraState.relativeWidth - + popupRect.width / cameraState.scale - + (blockGeometry.x + cameraState.relativeX) < + 0; + + const xDiff = needHorizontalSwitch + ? (popupRect.width + 8) / cameraState.scale + blockGeometry.width + : 0; + + return { + ...(yDiff < 0 && {top: `${yDiff}px`}), + ...(xDiff > 0 && {left: `-${xDiff}px`}), + maxHeight: `${cameraState.height - 5}px`, + }; + }, [block, cameraState, popupRect]); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/NodeStages.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/NodeStages.tsx index 09cccceee..8efe6f147 100644 --- a/packages/ui/src/ui/pages/query-tracker/Plan/NodeStages.tsx +++ b/packages/ui/src/ui/pages/query-tracker/Plan/NodeStages.tsx @@ -59,6 +59,8 @@ function prepareData(data: Stages, state?: NodeState, finishedAt?: string) { const stages: StageRow[] = []; const length = Object.keys(data).length; + if (!length) return stages; + let [currentStage, currentTime] = Object.entries(data[0])[0]; const startedTime = currentTime; for (let i = 1; i < length; i++) { diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/OperationNodeInfo.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/OperationNodeInfo.tsx index 271b5059b..1f1fa5474 100644 --- a/packages/ui/src/ui/pages/query-tracker/Plan/OperationNodeInfo.tsx +++ b/packages/ui/src/ui/pages/query-tracker/Plan/OperationNodeInfo.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import {RadioButton, RadioButtonOption} from '@gravity-ui/uikit'; +import {RadioButton, RadioButtonOption, RadioButtonWidth} from '@gravity-ui/uikit'; import {NodeDetails, NodeProgress} from './models/plan'; import cn from 'bem-cn-lite'; @@ -19,6 +19,8 @@ export interface OperationNodeInfoProps { details?: NodeDetails; schemas?: OperationSchemas; containerRef?: React.Ref; + className?: string; + radioWidth?: RadioButtonWidth; } export default function OperationNodeInfo({ @@ -26,6 +28,8 @@ export default function OperationNodeInfo({ details, schemas, containerRef, + radioWidth, + className, }: OperationNodeInfoProps) { const items: RadioButtonOption[] = []; if (hasStagesInfo(progress)) { @@ -42,17 +46,16 @@ export default function OperationNodeInfo({ } const [item, setItem] = React.useState(items[0]?.value); return ( -
-
- {items.length > 1 && ( - - )} -
+
+ {items.length > 1 && ( + + )} {item === 'stages' && } {item === 'details' && } {item === 'jobs' && } diff --git a/packages/ui/src/ui/pages/query-tracker/Plan/Plan.tsx b/packages/ui/src/ui/pages/query-tracker/Plan/Plan.tsx index f38db2d6d..bc29d2784 100644 --- a/packages/ui/src/ui/pages/query-tracker/Plan/Plan.tsx +++ b/packages/ui/src/ui/pages/query-tracker/Plan/Plan.tsx @@ -15,6 +15,9 @@ import {ProcessedGraph, ProcessedNode, preprocess, updateProgress} from './utils import Timeline from './Timeline/Timeline'; import './Plan.scss'; +import {GraphWrap} from './GraphEditor/GraphWrap'; +import {useSelector} from 'react-redux'; +import {getSettingsQueryTrackerNewGraphType} from '../../../store/selectors/settings-ts'; const block = cn('plan'); @@ -29,6 +32,7 @@ export default React.memo(function Plan({isActive, className, prepareNode}: Plan const [showLargeGraph, setShowLargeGraph] = React.useState(false); const planView = usePlanView(); const resultProgressShowMinimap = true; // can be setted as user setting on future + const newGraphType = useSelector(getSettingsQueryTrackerNewGraphType); const graph = React.useMemo(() => (plan ? preprocess(plan) : undefined), [plan]); @@ -44,12 +48,18 @@ export default React.memo(function Plan({isActive, className, prepareNode}: Plan {isLargeGraph && !showLargeGraph ? ( ) : ( - + <> + {newGraphType ? ( + + ) : ( + + )} + )} { + return getSetting(SettingName.QUERY_TRACKER.NEW_GRAPH_TYPE, NAMESPACES.QUERY_TRACKER) || false; +}); + export const getSettingsSchedulingExpandStaticConfiguration = createSelector( makeGetSetting, (getSetting) => {