From 9752633ebb8bdf1e0d2845214fdcc5acccb8ac00 Mon Sep 17 00:00:00 2001 From: Sparkle <1284531+baurine@users.noreply.github.com> Date: Mon, 14 Aug 2023 15:56:01 +0800 Subject: [PATCH] feat: fix and refine execution plan (#1568) --- pkg/apiserver/slowquery/model.go | 13 +- pkg/apiserver/slowquery/queries.go | 10 +- pkg/apiserver/slowquery/service.go | 13 +- pkg/apiserver/statement/models.go | 5 +- pkg/apiserver/statement/service.go | 50 +- pkg/apiserver/utils/binary_plan.go | 15 + .../src/client/api/models/slowquery-model.ts | 8 +- .../src/client/api/models/statement-model.ts | 6 + .../tidb-dashboard-client/swagger/spec.json | 8 + .../src/apps/SlowQuery/pages/Detail/index.tsx | 86 ++-- .../Statement/pages/Detail/PlanDetail.tsx | 80 ++- .../tidb-dashboard-lib/src/client/models.ts | 14 +- .../BinaryPlanColsSelector.tsx | 87 ++++ .../src/components/BinaryPlanTable/index.tsx | 467 ++++++++++++------ .../src/components/PlanText/index.tsx | 61 +++ .../TxtDownloadLink/index.module.less | 5 + .../src/components/TxtDownloadLink/index.tsx | 72 +++ .../src/components/index.ts | 2 + .../src/utils/local-download.ts | 15 + 19 files changed, 740 insertions(+), 277 deletions(-) create mode 100644 ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/BinaryPlanColsSelector.tsx create mode 100644 ui/packages/tidb-dashboard-lib/src/components/PlanText/index.tsx create mode 100644 ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.module.less create mode 100644 ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.tsx create mode 100644 ui/packages/tidb-dashboard-lib/src/utils/local-download.ts diff --git a/pkg/apiserver/slowquery/model.go b/pkg/apiserver/slowquery/model.go index 2a25e86dd5..31e93a0587 100644 --- a/pkg/apiserver/slowquery/model.go +++ b/pkg/apiserver/slowquery/model.go @@ -40,11 +40,10 @@ type Model struct { TxnStartTS string `gorm:"column:Txn_start_ts" json:"txn_start_ts"` // Detail - PrevStmt string `gorm:"column:Prev_stmt" json:"prev_stmt"` - Plan string `gorm:"column:Plan" json:"plan"` // deprecated, replaced by BinaryPlanText - BinaryPlan string `gorm:"column:Binary_plan" json:"binary_plan"` - BinaryPlanText string `gorm:"column:Binary_plan_text" proj:"tidb_decode_binary_plan(Binary_plan)" json:"binary_plan_text"` - Warnings datatypes.JSON `gorm:"column:Warnings" json:"warnings"` + PrevStmt string `gorm:"column:Prev_stmt" json:"prev_stmt"` + Plan string `gorm:"column:Plan" json:"plan"` // deprecated, replaced by BinaryPlanText + BinaryPlan string `gorm:"column:Binary_plan" json:"binary_plan"` + Warnings datatypes.JSON `gorm:"column:Warnings" json:"warnings"` // Basic IsInternal int `gorm:"column:Is_internal" json:"is_internal"` @@ -96,6 +95,10 @@ type Model struct { RocksdbBlockCacheHitCount uint `gorm:"column:Rocksdb_block_cache_hit_count" json:"rocksdb_block_cache_hit_count"` RocksdbBlockReadCount uint `gorm:"column:Rocksdb_block_read_count" json:"rocksdb_block_read_count"` RocksdbBlockReadByte uint `gorm:"column:Rocksdb_block_read_byte" json:"rocksdb_block_read_byte"` + + // Computed fields + BinaryPlanJSON string `json:"binary_plan_json"` // binary plan json format + BinaryPlanText string `json:"binary_plan_text"` // binary plan plain text } type Field struct { diff --git a/pkg/apiserver/slowquery/queries.go b/pkg/apiserver/slowquery/queries.go index 678e39eb75..d47709ef7a 100644 --- a/pkg/apiserver/slowquery/queries.go +++ b/pkg/apiserver/slowquery/queries.go @@ -109,19 +109,11 @@ func QuerySlowLogList(req *GetListRequest, sysSchema *utils.SysSchema, db *gorm. func QuerySlowLogDetail(req *GetDetailRequest, db *gorm.DB) (*Model, error) { var result Model err := db. - Select("*, (UNIX_TIMESTAMP(Time) + 0E0) AS timestamp, tidb_decode_binary_plan(Binary_plan) AS Binary_plan_text"). + Select("*, (UNIX_TIMESTAMP(Time) + 0E0) AS timestamp"). Where("Digest = ?", req.Digest). Where("Time = FROM_UNIXTIME(?)", req.Timestamp). Where("Conn_id = ?", req.ConnectID). First(&result).Error - if err != nil { - err = db. - Select("*, (UNIX_TIMESTAMP(Time) + 0E0) AS timestamp"). - Where("Digest = ?", req.Digest). - Where("Time = FROM_UNIXTIME(?)", req.Timestamp). - Where("Conn_id = ?", req.ConnectID). - First(&result).Error - } if err != nil { return nil, err } diff --git a/pkg/apiserver/slowquery/service.go b/pkg/apiserver/slowquery/service.go index d7858fa706..2ad0a9863a 100644 --- a/pkg/apiserver/slowquery/service.go +++ b/pkg/apiserver/slowquery/service.go @@ -100,14 +100,23 @@ func (s *Service) getDetails(c *gin.Context) { return } - // generate binary plan + // generate binary plan json // // Due to a kernel bug, the binary plan may fail to parse due to // encoding issues. Additionally, since the binary plan field is // not a required field, we can mask this error. // // See: https://github.com/pingcap/tidb-dashboard/issues/1515 - result.BinaryPlan, _ = utils.GenerateBinaryPlanJSON(result.BinaryPlan) + if result.BinaryPlan != "" { + // may failed but it's ok + result.BinaryPlanText, _ = utils.GenerateBinaryPlanText(db, result.BinaryPlan) + // may failed but it's ok + result.BinaryPlanJSON, _ = utils.GenerateBinaryPlanJSON(result.BinaryPlan) + + // reduce response size + result.BinaryPlan = "" + result.Plan = "" + } c.JSON(http.StatusOK, *result) } diff --git a/pkg/apiserver/statement/models.go b/pkg/apiserver/statement/models.go index 72685e1864..262adb27a0 100644 --- a/pkg/apiserver/statement/models.go +++ b/pkg/apiserver/statement/models.go @@ -81,9 +81,8 @@ type Model struct { AggIndexNames string `json:"index_names" agg:"ANY_VALUE(index_names)"` AggPlanCount int `json:"plan_count" agg:"COUNT(DISTINCT plan_digest)" related:"plan_digest"` AggPlan string `json:"plan" agg:"ANY_VALUE(plan)"` // deprecated, replaced by BinaryPlanText - AggPlanDigest string `json:"plan_digest" agg:"ANY_VALUE(plan_digest)"` AggBinaryPlan string `json:"binary_plan" agg:"ANY_VALUE(binary_plan)"` - AggBinaryPlanText string `json:"binary_plan_text" related:"binary_plan" agg:"tidb_decode_binary_plan(ANY_VALUE(binary_plan))"` + AggPlanDigest string `json:"plan_digest" agg:"ANY_VALUE(plan_digest)"` AggPlanHint *string `json:"plan_hint" agg:"ANY_VALUE(plan_hint)"` // RocksDB AggMaxRocksdbDeleteSkippedCount uint `json:"max_rocksdb_delete_skipped_count" agg:"MAX(max_rocksdb_delete_skipped_count)"` @@ -99,6 +98,8 @@ type Model struct { // Computed fields RelatedSchemas string `json:"related_schemas"` PlanCanBeBound bool `json:"plan_can_be_bound"` + BinaryPlanJSON string `json:"binary_plan_json"` + BinaryPlanText string `json:"binary_plan_text"` } // tableNames example: "d1.a1,d2.a2,d1.a1,d3.a3" diff --git a/pkg/apiserver/statement/service.go b/pkg/apiserver/statement/service.go index 96eae34b1e..8e467325c2 100644 --- a/pkg/apiserver/statement/service.go +++ b/pkg/apiserver/statement/service.go @@ -44,27 +44,30 @@ func newService(p ServiceParams, ff *featureflag.Registry) *Service { func registerRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint := r.Group("/statements") - endpoint.Use(auth.MWAuthRequired()) - endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient)) { endpoint.GET("/download", s.downloadHandler) - endpoint.POST("/download/token", s.downloadTokenHandler) - endpoint.GET("/config", s.configHandler) - endpoint.POST("/config", auth.MWRequireWritePriv(), s.modifyConfigHandler) - endpoint.GET("/stmt_types", s.stmtTypesHandler) - endpoint.GET("/list", s.listHandler) - endpoint.GET("/plans", s.plansHandler) - endpoint.GET("/plan/detail", s.planDetailHandler) - - endpoint.GET("/available_fields", s.getAvailableFields) - - binding := endpoint.Group("/plan/binding") - binding.Use(s.planBindingFeatureFlag.VersionGuard()) + endpoint.Use(auth.MWAuthRequired()) + endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient)) { - binding.GET("", s.getPlanBindingHandler) - binding.POST("", s.createPlanBindingHandler) - binding.DELETE("", s.dropPlanBindingHandler) + endpoint.POST("/download/token", s.downloadTokenHandler) + + endpoint.GET("/config", s.configHandler) + endpoint.POST("/config", auth.MWRequireWritePriv(), s.modifyConfigHandler) + endpoint.GET("/stmt_types", s.stmtTypesHandler) + endpoint.GET("/list", s.listHandler) + endpoint.GET("/plans", s.plansHandler) + endpoint.GET("/plan/detail", s.planDetailHandler) + + endpoint.GET("/available_fields", s.getAvailableFields) + + binding := endpoint.Group("/plan/binding") + binding.Use(s.planBindingFeatureFlag.VersionGuard()) + { + binding.GET("", s.getPlanBindingHandler) + binding.POST("", s.createPlanBindingHandler) + binding.DELETE("", s.dropPlanBindingHandler) + } } } } @@ -230,9 +233,16 @@ func (s *Service) planDetailHandler(c *gin.Context) { return } - // get binary plan - // may failed but it's ok - result.AggBinaryPlan, _ = utils.GenerateBinaryPlanJSON(result.AggBinaryPlan) + if result.AggBinaryPlan != "" { + // may failed but it's ok + result.BinaryPlanText, _ = utils.GenerateBinaryPlanText(db, result.AggBinaryPlan) + // may failed but it's ok + result.BinaryPlanJSON, _ = utils.GenerateBinaryPlanJSON(result.AggBinaryPlan) + + // reduce response size + result.AggBinaryPlan = "" + result.AggPlan = "" + } c.JSON(http.StatusOK, result) } diff --git a/pkg/apiserver/utils/binary_plan.go b/pkg/apiserver/utils/binary_plan.go index c813245d1d..7a748d2785 100644 --- a/pkg/apiserver/utils/binary_plan.go +++ b/pkg/apiserver/utils/binary_plan.go @@ -15,6 +15,7 @@ import ( "github.com/pingcap/tipb/go-tipb" json "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/runtime/protoimpl" + "gorm.io/gorm" ) const ( @@ -975,3 +976,17 @@ func formatJSON(s string) (*simplejson.Json, error) { return simplejson.NewJson([]byte(s)) } + +///////////////// + +func GenerateBinaryPlanText(db *gorm.DB, b string) (string, error) { + type binaryPlanText struct { + Text string `gorm:"column:binary_plan_text"` + } + ret := &binaryPlanText{} + err := db.Raw(fmt.Sprintf("select tidb_decode_binary_plan('%s') as binary_plan_text", b)).Find(ret).Error + if err != nil { + return "", err + } + return ret.Text, err +} diff --git a/ui/packages/tidb-dashboard-client/src/client/api/models/slowquery-model.ts b/ui/packages/tidb-dashboard-client/src/client/api/models/slowquery-model.ts index 41a25338ce..17db94fb4a 100644 --- a/ui/packages/tidb-dashboard-client/src/client/api/models/slowquery-model.ts +++ b/ui/packages/tidb-dashboard-client/src/client/api/models/slowquery-model.ts @@ -39,7 +39,13 @@ export interface SlowqueryModel { */ 'binary_plan'?: string; /** - * + * Computed fields + * @type {string} + * @memberof SlowqueryModel + */ + 'binary_plan_json'?: string; + /** + * binary plan plain text * @type {string} * @memberof SlowqueryModel */ diff --git a/ui/packages/tidb-dashboard-client/src/client/api/models/statement-model.ts b/ui/packages/tidb-dashboard-client/src/client/api/models/statement-model.ts index 2e3ae1ef22..9f97e37432 100644 --- a/ui/packages/tidb-dashboard-client/src/client/api/models/statement-model.ts +++ b/ui/packages/tidb-dashboard-client/src/client/api/models/statement-model.ts @@ -194,6 +194,12 @@ export interface StatementModel { * @memberof StatementModel */ 'binary_plan'?: string; + /** + * + * @type {string} + * @memberof StatementModel + */ + 'binary_plan_json'?: string; /** * * @type {string} diff --git a/ui/packages/tidb-dashboard-client/swagger/spec.json b/ui/packages/tidb-dashboard-client/swagger/spec.json index 6b21009c79..b5460c621b 100644 --- a/ui/packages/tidb-dashboard-client/swagger/spec.json +++ b/ui/packages/tidb-dashboard-client/swagger/spec.json @@ -4906,7 +4906,12 @@ "binary_plan": { "type": "string" }, + "binary_plan_json": { + "description": "Computed fields", + "type": "string" + }, "binary_plan_text": { + "description": "binary plan plain text", "type": "string" }, "commit_backoff_time": { @@ -5314,6 +5319,9 @@ "binary_plan": { "type": "string" }, + "binary_plan_json": { + "type": "string" + }, "binary_plan_text": { "type": "string" }, diff --git a/ui/packages/tidb-dashboard-lib/src/apps/SlowQuery/pages/Detail/index.tsx b/ui/packages/tidb-dashboard-lib/src/apps/SlowQuery/pages/Detail/index.tsx index 5f466972dd..bc4130f561 100644 --- a/ui/packages/tidb-dashboard-lib/src/apps/SlowQuery/pages/Detail/index.tsx +++ b/ui/packages/tidb-dashboard-lib/src/apps/SlowQuery/pages/Detail/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react' +import React, { useState, useContext, useMemo } from 'react' import { Space, Modal, Tabs, Typography } from 'antd' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' @@ -10,26 +10,25 @@ import formatSql from '@lib/utils/sqlFormatter' import { AnimatedSkeleton, BinaryPlanTable, + PlanText, CopyLink, Descriptions, ErrorBar, Expand, Head, HighlightSQL, - Pre, TextWithInfo } from '@lib/components' +import { + VisualPlanThumbnailView, + VisualPlanView +} from '@lib/components/VisualPlan' import { useVersionedLocalStorageState } from '@lib/utils/useVersionedLocalStorageState' import { telemetry } from '../../utils/telemetry' import DetailTabs from './DetailTabs' import { SlowQueryContext } from '../../context' -import { - VisualPlanThumbnailView, - VisualPlanView -} from '@lib/components/VisualPlan' - export interface IPageQuery { connectId?: string digest?: string @@ -56,7 +55,13 @@ function DetailPage() { ) ) - const binaryPlan = data?.binary_plan && JSON.parse(data.binary_plan) + const binaryPlanObj = useMemo(() => { + const json = data?.binary_plan_json ?? data?.binary_plan + if (json) { + return JSON.parse(json) + } + return undefined + }, [data?.binary_plan, data?.binary_plan_json]) const [detailExpand, setDetailExpand] = useVersionedLocalStorageState( SLOW_QUERY_DETAIL_EXPAND, @@ -73,13 +78,11 @@ function DetailPage() { setDetailExpand((prev) => ({ ...prev, prev_query: !prev.prev_query })) const toggleQuery = () => setDetailExpand((prev) => ({ ...prev, query: !prev.query })) - // const togglePlan = () => - // setDetailExpand((prev) => ({ ...prev, plan: !prev.plan })) - const [isVpVisible, setIsVpVisable] = useState(false) + const [isVpVisible, setIsVpVisible] = useState(false) const toggleVisualPlan = (action: 'open' | 'close') => { telemetry.toggleVisualPlanModal(action) - setIsVpVisable(!isVpVisible) + setIsVpVisible(!isVpVisible) } return ( @@ -167,56 +170,45 @@ function DetailPage() { ) })()} - {(binaryPlan || !!data.plan) && ( + {(binaryPlanObj || !!data.plan) && ( <> {t('slow_query.detail.plan.title')} telemetry.clickPlanTabs(key, data.digest!) } > - - - - {/* */} - - - } - > - {/* - */} -
{data.binary_plan_text ?? data.plan}
-
-
-
- - {binaryPlan && !binaryPlan.discardedDueToTooLong && ( + {!!data.binary_plan_text && ( - +
)} - {binaryPlan && !binaryPlan.discardedDueToTooLong && ( + + + + + {binaryPlanObj && !binaryPlanObj.discardedDueToTooLong && ( - +
toggleVisualPlan('open')}> - +
diff --git a/ui/packages/tidb-dashboard-lib/src/apps/Statement/pages/Detail/PlanDetail.tsx b/ui/packages/tidb-dashboard-lib/src/apps/Statement/pages/Detail/PlanDetail.tsx index 79fe5b147b..0e9c47b626 100644 --- a/ui/packages/tidb-dashboard-lib/src/apps/Statement/pages/Detail/PlanDetail.tsx +++ b/ui/packages/tidb-dashboard-lib/src/apps/Statement/pages/Detail/PlanDetail.tsx @@ -1,18 +1,22 @@ -import React, { useState, useContext } from 'react' +import React, { useState, useContext, useMemo } from 'react' import { Space, Tabs, Modal } from 'antd' import { useTranslation } from 'react-i18next' import { AnimatedSkeleton, BinaryPlanTable, + PlanText, Card, CopyLink, Descriptions, ErrorBar, Expand, HighlightSQL, - Pre, TextWithInfo } from '@lib/components' +import { + VisualPlanThumbnailView, + VisualPlanView +} from '@lib/components/VisualPlan' import { useClientRequest } from '@lib/utils/useClientRequest' import formatSql from '@lib/utils/sqlFormatter' import { useVersionedLocalStorageState } from '@lib/utils/useVersionedLocalStorageState' @@ -23,11 +27,6 @@ import { useSchemaColumns } from '../../utils/useSchemaColumns' import { telemetry } from '../../utils/telemetry' import { StatementContext } from '../../context' -import { - VisualPlanThumbnailView, - VisualPlanView -} from '@lib/components/VisualPlan' - export interface IQuery extends IPageQuery { plans: string[] allPlans: number @@ -62,7 +61,13 @@ function PlanDetail({ query }: IPlanDetailProps) { ) const isLoading = isDataLoading || isSchemaLoading - const binaryPlan = data?.binary_plan && JSON.parse(data.binary_plan) + const binaryPlanObj = useMemo(() => { + const json = data?.binary_plan_json ?? data?.binary_plan + if (json) { + return JSON.parse(json) + } + return undefined + }, [data?.binary_plan, data?.binary_plan_json]) const [isVpVisible, setIsVpVisable] = useState(false) const toggleVisualPlan = (action: 'open' | 'close') => { @@ -85,8 +90,6 @@ function PlanDetail({ query }: IPlanDetailProps) { setDetailExpand((prev) => ({ ...prev, prev_query: !prev.prev_query })) const toggleQuery = () => setDetailExpand((prev) => ({ ...prev, query: !prev.query })) - // const togglePlan = () => - // setDetailExpand((prev) => ({ ...prev, plan: !prev.plan })) let titleKey if (query.allPlans === 1) { @@ -172,59 +175,46 @@ function PlanDetail({ query }: IPlanDetailProps) { ) : null} - {(binaryPlan || data.plan) && ( + {(binaryPlanObj || !!data.plan) && ( <> {t('statement.pages.detail.desc.plans.execution.title')} telemetry.clickPlanTabs(key, data.digest!) } > - - - - {/* */} - - - } - > - {/* - */} -
{data.binary_plan_text ?? data.plan}
-
-
-
- - {binaryPlan && !binaryPlan.discardedDueToTooLong && ( + {!!data.binary_plan_text && ( - +
)} - {binaryPlan && !binaryPlan.main.discardedDueToTooLong && ( + + + + + {binaryPlanObj && !binaryPlanObj.main.discardedDueToTooLong && ( - +
toggleVisualPlan('open')}> - +
diff --git a/ui/packages/tidb-dashboard-lib/src/client/models.ts b/ui/packages/tidb-dashboard-lib/src/client/models.ts index 8f57ae04e7..d6e2ea9994 100644 --- a/ui/packages/tidb-dashboard-lib/src/client/models.ts +++ b/ui/packages/tidb-dashboard-lib/src/client/models.ts @@ -2177,7 +2177,13 @@ export interface SlowqueryModel { */ 'binary_plan'?: string; /** - * + * Computed fields + * @type {string} + * @memberof SlowqueryModel + */ + 'binary_plan_json'?: string; + /** + * binary plan plain text * @type {string} * @memberof SlowqueryModel */ @@ -2934,6 +2940,12 @@ export interface StatementModel { * @memberof StatementModel */ 'binary_plan'?: string; + /** + * + * @type {string} + * @memberof StatementModel + */ + 'binary_plan_json'?: string; /** * * @type {string} diff --git a/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/BinaryPlanColsSelector.tsx b/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/BinaryPlanColsSelector.tsx new file mode 100644 index 0000000000..9e45919a65 --- /dev/null +++ b/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/BinaryPlanColsSelector.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { Checkbox, Popover, Space } from 'antd' +import { DownOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { IColumn } from 'office-ui-fabric-react/lib/DetailsList' +import { addTranslationResource } from '@lib/utils/i18n' + +const translations = { + en: { + trigger_text: 'Columns' + }, + zh: { + trigger_text: '选择列' + } +} + +for (const key in translations) { + addTranslationResource(key, { + component: { + binaryPlanColsSelector: translations[key] + } + }) +} + +export interface IColumnKeys { + [key: string]: boolean +} + +export interface IBinaryPlanColsSelectorProps { + columns: IColumn[] + visibleColumnKeys: IColumnKeys + onChange?: (visibleKeys: IColumnKeys) => void +} + +export function BinaryPlanColsSelector({ + columns, + visibleColumnKeys, + onChange +}: IBinaryPlanColsSelectorProps) { + const { t } = useTranslation() + + function handleCheckChange(e, column: IColumn) { + const checked = e.target.checked + const newVisibleKeys = { + ...visibleColumnKeys, + [column.key]: checked + } + onChange && onChange(newVisibleKeys) + } + + const content = ( +
+ + {columns.map((column) => ( + handleCheckChange(e, column)} + > + {column['extra']} + + ))} + +
+ ) + + return ( + + + {t('component.binaryPlanColsSelector.trigger_text')} + + + ) +} diff --git a/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/index.tsx b/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/index.tsx index 98ec885f21..005e26db7e 100644 --- a/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/index.tsx +++ b/ui/packages/tidb-dashboard-lib/src/components/BinaryPlanTable/index.tsx @@ -1,240 +1,417 @@ import { IColumn } from 'office-ui-fabric-react' import React, { useMemo } from 'react' import CardTable from '../CardTable' -import { getValueFormat } from '@baurine/grafana-value-formats' import { Tooltip } from 'antd' +import { CopyLink, TxtDownloadLink } from '@lib/components' +import { BinaryPlanColsSelector } from './BinaryPlanColsSelector' +import { EyeOutlined } from '@ant-design/icons' -type BinaryPlanItem = { - name: string // id - - estRows: number - cost: number // estCost - actRows: number - - taskType: string // task - storeType: string // task - - accessObjects: any[] // access object - - rootBasicExecInfo: object // execution info - rootGroupExecInfo: any[] // execution info - copExecInfo: object // execution info - - operatorInfo: string // operator info - - memoryBytes: string // memory - diskBytes: string // disk - - children?: BinaryPlanItem[] - level: number -} - -type BinaryPlan = { - discardedDueToTooLong: boolean - withRuntimeStats: boolean - main: BinaryPlanItem -} +const COLUM_KEYS = [ + 'id', + 'estRows', + 'estCost', + 'actRows', + 'task', + 'accessObject', + 'executionInfo', + 'operatorInfo', + 'memory', + 'disk' +] as const +type COLUM_KEYS_UNION = typeof COLUM_KEYS[number] +type BinaryPlanItem = Record +type BinaryPlanFiledPosition = Record< + COLUM_KEYS_UNION, + { + start: number + len: number + } +> type BinaryPlanTableProps = { - data: BinaryPlan + data: string + downloadFileName: string } -function convertBinaryPlanToArray(binaryPlan: BinaryPlan): BinaryPlanItem[] { +// see binary_plan_text example from sample-data/detail-res.json +function convertBinaryPlanTextToArray( + binaryPlanText: string +): BinaryPlanItem[] { const result: BinaryPlanItem[] = [] - const stack: BinaryPlanItem[] = [binaryPlan.main] - stack[0].level = 0 - while (stack.length > 0) { - const item = stack.pop()! - result.push(item) + let positions: BinaryPlanFiledPosition | null = null - if (item.children !== undefined) { - for (let i = item.children.length - 1; i >= 0; i--) { - const child = item.children[i] - child.level = item.level + 1 - stack.push(child) - } - } - } - return result -} - -function getTableName(item: BinaryPlanItem): string { - let tableName = '' - if (!item?.accessObjects?.length) return '' - - const scanObject = item.accessObjects.find((obj) => - Object.keys(obj).includes('scanObject') - ) + // we can't simply split by '\n', because operator info column may contain '\n' + // for example, execution plan for "select `tidb_decode_binary_plan` ( ? ) as `binary_plan_text`;" + // const lines = binaryPlanText.split('\n') - if (scanObject) { - tableName = scanObject['scanObject']['table'] + const headerEndPos = binaryPlanText.indexOf('\n', 1) + const headerLine = binaryPlanText.slice(1, headerEndPos) + if (!headerLine.startsWith('| id')) { + console.error('invalid binary plan text format') + return result } + const headerLineLen = headerLine.length - return tableName -} - -function getExecutionInfo(item: BinaryPlanItem) { - let execInfo: string[] = [] - if (Object.keys(item.rootBasicExecInfo).length > 0) { - execInfo.push(JSON.stringify(item.rootBasicExecInfo)) + const headers = headerLine.split('|') + // 0: "" + // 1: " id " + // 2: " estRows " + // 3: " estCost " + // 4: " actRows " + // 5: " task " + // 6: " access object " + // 7: " execution info " + // 8: " operator info " + // 9: " memory " + // 10: " disk " + // 11: "" + if (headers.length !== 12) { + console.error('invalid binary plan text format') + return result } - if (item.rootGroupExecInfo.length > 0) { - item.rootGroupExecInfo - .filter((i) => !!i) // filter out the NULL value - .forEach((info) => { - execInfo.push(JSON.stringify(info)) - }) - } - if (Object.keys(item.copExecInfo).length > 0) { - execInfo.push(JSON.stringify(item.copExecInfo)) + positions = { + id: { + start: 0, + len: headers[1].length + }, + estRows: { + start: 0, + len: headers[2].length + }, + estCost: { + start: 0, + len: headers[3].length + }, + actRows: { + start: 0, + len: headers[4].length + }, + task: { + start: 0, + len: headers[5].length + }, + accessObject: { + start: 0, + len: headers[6].length + }, + executionInfo: { + start: 0, + len: headers[7].length + }, + operatorInfo: { + start: 0, + len: headers[8].length + }, + memory: { + start: 0, + len: headers[9].length + }, + disk: { + start: 0, + len: headers[10].length + } } - execInfo = execInfo.map((info) => - info.replaceAll('"', '').replaceAll(',', ', ') - ) - return execInfo -} + positions.id.start = 1 + positions.estRows.start = positions.id.start + positions.id.len + 1 + positions.estCost.start = positions.estRows.start + positions.estRows.len + 1 + positions.actRows.start = positions.estCost.start + positions.estCost.len + 1 + positions.task.start = positions.actRows.start + positions.actRows.len + 1 + positions.accessObject.start = positions.task.start + positions.task.len + 1 + positions.executionInfo.start = + positions.accessObject.start + positions.accessObject.len + 1 + positions.operatorInfo.start = + positions.executionInfo.start + positions.executionInfo.len + 1 + positions.memory.start = + positions.operatorInfo.start + positions.operatorInfo.len + 1 + positions.disk.start = positions.memory.start + positions.memory.len + 1 + + let lineIdx = 1 + while (true) { + const lineStart = 1 + (headerLineLen + 1) * lineIdx + const lineEnd = 1 + (headerLineLen + 1) * (lineIdx + 1) + if (lineEnd > binaryPlanText.length) { + break + } + lineIdx++ -function getMemorySize(item: BinaryPlanItem): string { - if (item.memoryBytes === 'N/A') { - return 'N/A' + const line = binaryPlanText.slice(lineStart, lineEnd) + const item: BinaryPlanItem = { + id: line + .slice(positions.id.start + 1, positions.id.start + positions.id.len) + .trimEnd(), // start+1 for removing the leading white space + estRows: line + .slice( + positions.estRows.start, + positions.estRows.start + positions.estRows.len + ) + .trim(), + estCost: line + .slice( + positions.estCost.start, + positions.estCost.start + positions.estCost.len + ) + .trim(), + actRows: line + .slice( + positions.actRows.start, + positions.actRows.start + positions.actRows.len + ) + .trim(), + task: line + .slice(positions.task.start, positions.task.start + positions.task.len) + .trim(), + accessObject: line + .slice( + positions.accessObject.start, + positions.accessObject.start + positions.accessObject.len + ) + .trim(), + executionInfo: line + .slice( + positions.executionInfo.start, + positions.executionInfo.start + positions.executionInfo.len + ) + .trim(), + operatorInfo: line + .slice( + positions.operatorInfo.start, + positions.operatorInfo.start + positions.operatorInfo.len + ) + .trim(), + memory: line + .slice( + positions.memory.start, + positions.memory.start + positions.memory.len + ) + .trim(), + disk: line + .slice(positions.disk.start, positions.disk.start + positions.disk.len) + .trim() + } + result.push(item) } - return getValueFormat('bytes')(Number(item.memoryBytes), 1) + + return result } -function getDiskSize(item: BinaryPlanItem): string { - if (item.diskBytes === 'N/A') { - return 'N/A' +export const BinaryPlanTable: React.FC = ({ + data, + downloadFileName +}) => { + const arr = useMemo(() => convertBinaryPlanTextToArray(data), [data]) + + function hideColumn(columnKey: COLUM_KEYS_UNION) { + setVisibleColumnKeys((prev) => { + return { + ...prev, + [columnKey]: false + } + }) } - return getValueFormat('bytes')(Number(item.diskBytes), 1) -} -export const BinaryPlanTable: React.FC = ({ data }) => { - const arr = useMemo(() => convertBinaryPlanToArray(data), [data]) const columns: IColumn[] = useMemo(() => { return [ { - name: 'id', - key: 'name', + name: ( +
+ id hideColumn('id')} /> +
+ ) as any, + extra: 'id', + key: 'id', minWidth: 100, - maxWidth: 300, + maxWidth: 600, onRender: (row: BinaryPlanItem) => { return ( - - {row.level > 0 && '└─'} - {row.name} - + + + {row.id} + + ) } }, { - name: 'estRows', + name: ( +
+ estRows hideColumn('estRows')} /> +
+ ) as any, + extra: 'estRows', key: 'estRows', minWidth: 100, maxWidth: 120, onRender: (row: BinaryPlanItem) => { - return row.estRows.toFixed(2) + return row.estRows } }, { - name: 'estCost', + name: ( +
+ estCost hideColumn('estCost')} /> +
+ ) as any, + extra: 'estCost', key: 'estCost', minWidth: 100, maxWidth: 120, onRender: (row: BinaryPlanItem) => { - return (row.cost ?? 0).toFixed(2) + return row.estCost } }, { - name: 'actRows', + name: ( +
+ actRows hideColumn('actRows')} /> +
+ ) as any, + extra: 'actRows', key: 'actRows', minWidth: 100, maxWidth: 120, onRender: (row: BinaryPlanItem) => { - return row.actRows.toFixed(2) + return row.actRows } }, { - name: 'task', - key: 'taskType', + name: ( +
+ task hideColumn('task')} /> +
+ ) as any, + extra: 'task', + key: 'task', minWidth: 60, maxWidth: 100, onRender: (row: BinaryPlanItem) => { - let task = row.taskType - if (task !== 'root') { - task += `[${row.storeType}]` - } - return task + return row.task } }, { - name: 'access object', - key: 'accessObjects', - minWidth: 100, - maxWidth: 120, + name: ( +
+ access object{' '} + hideColumn('accessObject')} /> +
+ ) as any, + extra: 'access object', + key: 'accessObject', + minWidth: 120, + maxWidth: 140, onRender: (row: BinaryPlanItem) => { - const tableName = getTableName(row) - let content = !!tableName ? `table: ${tableName}` : '' - return content && {content} + return {row.accessObject} } }, { - name: 'execution info', - key: 'rootGroupExecInfo', - minWidth: 100, + name: ( +
+ execution info{' '} + hideColumn('executionInfo')} /> +
+ ) as any, + extra: 'execution info', + key: 'executionInfo', + minWidth: 120, maxWidth: 300, onRender: (row: BinaryPlanItem) => { - const execInfo = getExecutionInfo(row) return ( - - {execInfo.map((info, idx) => ( -
{info}
- ))} - - } - > - {execInfo.join(', ')} -
+ {row.executionInfo} ) } }, { - name: 'operator info', + name: ( +
+ operator info{' '} + hideColumn('operatorInfo')} /> +
+ ) as any, + extra: 'operator info', key: 'operatorInfo', - minWidth: 100, + minWidth: 120, maxWidth: 300, onRender: (row: BinaryPlanItem) => { // truncate the string if it's too long // operation info may be super super long - const truncateLength = 1000 - let truncatedStr = row.operatorInfo + const truncateLength = 100 + let truncatedStr = row.operatorInfo ?? '' if (truncatedStr.length > truncateLength) { truncatedStr = row.operatorInfo.slice(0, truncateLength) + '...' } - return {truncatedStr} + const truncateTooltipLen = 2000 + let truncatedTooltipStr = row.operatorInfo ?? '' + if (truncatedTooltipStr.length > truncateTooltipLen) { + truncatedTooltipStr = + row.operatorInfo.slice(0, truncateTooltipLen) + + '...(too long to show, copy or download to analyze)' + } + return {truncatedStr} } }, { - name: 'memory', - key: 'memoryBytes', - minWidth: 60, + name: ( +
+ memory hideColumn('memory')} /> +
+ ) as any, + extra: 'memory', + key: 'memory', + minWidth: 80, maxWidth: 100, onRender: (row: BinaryPlanItem) => { - return getMemorySize(row) + return row.memory } }, { - name: 'disk', - key: 'diskBytes', + name: ( +
+ disk hideColumn('disk')} /> +
+ ) as any, + extra: 'disk', + key: 'disk', minWidth: 60, maxWidth: 100, onRender: (row: BinaryPlanItem) => { - return getDiskSize(row) + return row.disk } } ] }, []) - return + const [visibleColumnKeys, setVisibleColumnKeys] = React.useState(() => { + return COLUM_KEYS.reduce((acc, cur) => { + acc[cur] = true + return acc + }, {}) + }) + + const filteredColumns = useMemo(() => { + return columns.filter((c) => visibleColumnKeys[c.key as COLUM_KEYS_UNION]) + }, [columns, visibleColumnKeys]) + + if (arr.length > 0) { + return ( + <> +
+ + +
+ +
+
+ + + ) + } + return ( +
+ Parse plan text failed, original content: +
{data}
+
+ ) } diff --git a/ui/packages/tidb-dashboard-lib/src/components/PlanText/index.tsx b/ui/packages/tidb-dashboard-lib/src/components/PlanText/index.tsx new file mode 100644 index 0000000000..17039cfe81 --- /dev/null +++ b/ui/packages/tidb-dashboard-lib/src/components/PlanText/index.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react' +import { CopyLink, TxtDownloadLink, Pre } from '@lib/components' + +type BinaryPlanTextProps = { + data: string + downloadFileName: string +} + +// mysql> select tidb_decode_binary_plan("AgQgAQ=="); +// +-------------------------------------+ +// | tidb_decode_binary_plan("AgQgAQ==") | +// +-------------------------------------+ +// | (plan discarded because too long) | +// +-------------------------------------+ +// 1 row in set (0.00 sec) + +const DISCARDED_TOO_LONG = 'plan discarded because too long' + +const MAX_SHOW_LEN = 500 * 1024 // 500KB + +export const PlanText: React.FC = ({ + data, + downloadFileName +}) => { + const discardedDueToTooLong = useMemo(() => { + return data + .slice(0, DISCARDED_TOO_LONG.length + 10) + .includes(DISCARDED_TOO_LONG) + }, [data]) + + const truncatedStr = useMemo(() => { + let str = data + if (str.length > MAX_SHOW_LEN) { + str = + str.slice(0, MAX_SHOW_LEN) + + '\n...(too long to show, copy or download to analyze)' + } + // binary_plan_text field starts with '\n' which will show an extra empty line + // plan field starts with `\t` + if (str.startsWith('\n')) { + // remove the first empty line + str = str.slice(1) + } + return str + }, [data]) + + if (discardedDueToTooLong) { + return
{data}
+ } + return ( + <> +
+ + +
+
+        {truncatedStr}
+      
+ + ) +} diff --git a/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.module.less b/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.module.less new file mode 100644 index 0000000000..5d492d7762 --- /dev/null +++ b/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.module.less @@ -0,0 +1,5 @@ +@import 'antd/es/style/themes/default.less'; + +.successTxt { + color: @success-color; +} diff --git a/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.tsx b/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.tsx new file mode 100644 index 0000000000..6b9819ebbb --- /dev/null +++ b/ui/packages/tidb-dashboard-lib/src/components/TxtDownloadLink/index.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useTimeoutFn } from 'react-use' +import { CheckOutlined, DownloadOutlined } from '@ant-design/icons' +import { addTranslationResource } from '@lib/utils/i18n' +import { downloadTxt } from '@lib/utils/local-download' + +import styles from './index.module.less' + +export interface ITxtDownloadLinkProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLSpanElement + > { + data?: string + fileName?: string +} + +const translations = { + en: { + download: 'Download', + success: 'Downloaded' + }, + zh: { + download: '下载', + success: '已下载' + } +} + +for (const key in translations) { + addTranslationResource(key, { + component: { + txtDownloadLink: translations[key] + } + }) +} + +function TxtDownloadLink({ + data, + fileName, + ...otherProps +}: ITxtDownloadLinkProps) { + const { t } = useTranslation() + const [showDownload, setShowDownloaded] = useState(false) + + const reset = useTimeoutFn(() => { + setShowDownloaded(false) + }, 1500)[2] + + const handleDownload = () => { + downloadTxt(data ?? '', fileName ?? 'data.txt') + setShowDownloaded(true) + reset() + } + + return ( + + {!showDownload && ( + + {t(`component.txtDownloadLink.download`)} + + )} + {showDownload && ( + + {t('component.txtDownloadLink.success')} + + )} + + ) +} + +export default React.memo(TxtDownloadLink) diff --git a/ui/packages/tidb-dashboard-lib/src/components/index.ts b/ui/packages/tidb-dashboard-lib/src/components/index.ts index d2187e42a6..26063a5898 100644 --- a/ui/packages/tidb-dashboard-lib/src/components/index.ts +++ b/ui/packages/tidb-dashboard-lib/src/components/index.ts @@ -26,6 +26,7 @@ export * from './Expand' export { default as Expand } from './Expand' export * from './CopyLink' export { default as CopyLink } from './CopyLink' +export { default as TxtDownloadLink } from './TxtDownloadLink' export * from './ColumnsSelector' export { default as ColumnsSelector } from './ColumnsSelector' export * from './Toolbar' @@ -56,6 +57,7 @@ export { default as DrawerFooter } from './DrawerFooter' export * from './VisualPlan' export * from './BinaryPlanTable' +export * from './PlanText' export { default as LanguageDropdown } from './LanguageDropdown' export { default as ParamsPageWrapper } from './ParamsPageWrapper' diff --git a/ui/packages/tidb-dashboard-lib/src/utils/local-download.ts b/ui/packages/tidb-dashboard-lib/src/utils/local-download.ts new file mode 100644 index 0000000000..3a197286c3 --- /dev/null +++ b/ui/packages/tidb-dashboard-lib/src/utils/local-download.ts @@ -0,0 +1,15 @@ +export function downloadTxt(data: string, fileName: string) { + const fileUrl = URL.createObjectURL( + new Blob([data], { + type: 'text/plain;charset=utf-8;' + }) + ) + const a = document.createElement('a') + document.body.appendChild(a) + a.href = fileUrl + a.download = fileName + a.click() + setTimeout(() => { + document.body.removeChild(a) + }, 0) +}