From 220b2b4ac47f7c5b653349845e896cf557b8842a Mon Sep 17 00:00:00 2001 From: Kim Santiago <31145923+kisantia@users.noreply.github.com> Date: Thu, 29 Jun 2023 13:24:22 -1000 Subject: [PATCH] Initial changes for reusable QDS report (#23527) * initial changes for making a QDS report with placeholders * Add icon for configure button * Add another example report to show layout * move files * add placeholder names to components and cleanup toolbar * cleanup * switch to createViews() instead of createTop and BottomSections() * add QueryStoreView class for the different components in a report * cleanup * add more comments * fix yarn not running for query store extension folder * add missing break * change one more view to container --- build/npm/dirs.js | 1 + extensions/query-store/images/dark/gear.svg | 3 + extensions/query-store/images/light/gear.svg | 3 + extensions/query-store/package.json | 13 +- extensions/query-store/package.nls.json | 5 +- .../query-store/src/common/constants.ts | 23 +++ extensions/query-store/src/common/utils.ts | 78 ++++++++++ extensions/query-store/src/extension.ts | 7 +- .../src/reports/baseQueryStoreReport.ts | 146 ++++++++++++++++++ .../src/reports/overallResourceConsumption.ts | 35 +++++ .../query-store/src/reports/queryStoreView.ts | 37 +++++ .../reports/topResourceConsumingQueries.ts | 31 ++++ 12 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 extensions/query-store/images/dark/gear.svg create mode 100644 extensions/query-store/images/light/gear.svg create mode 100644 extensions/query-store/src/common/constants.ts create mode 100644 extensions/query-store/src/common/utils.ts create mode 100644 extensions/query-store/src/reports/baseQueryStoreReport.ts create mode 100644 extensions/query-store/src/reports/overallResourceConsumption.ts create mode 100644 extensions/query-store/src/reports/queryStoreView.ts create mode 100644 extensions/query-store/src/reports/topResourceConsumingQueries.ts diff --git a/build/npm/dirs.js b/build/npm/dirs.js index b519b8f96c..a7eb2bd8dd 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -44,6 +44,7 @@ const dirs = [ 'extensions/notebook-renderers', 'extensions/profiler', 'extensions/query-history', + 'extensions/query-store', 'extensions/resource-deployment', 'extensions/schema-compare', 'extensions/search-result', diff --git a/extensions/query-store/images/dark/gear.svg b/extensions/query-store/images/dark/gear.svg new file mode 100644 index 0000000000..0a8f58d309 --- /dev/null +++ b/extensions/query-store/images/dark/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/query-store/images/light/gear.svg b/extensions/query-store/images/light/gear.svg new file mode 100644 index 0000000000..0040bfcff1 --- /dev/null +++ b/extensions/query-store/images/light/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/query-store/package.json b/extensions/query-store/package.json index dd505e9d18..33415cc6e8 100644 --- a/extensions/query-store/package.json +++ b/extensions/query-store/package.json @@ -13,7 +13,8 @@ "icon": "images/extension.png", "aiKey": "29a207bb14f84905966a8f22524cb730-25407f35-11b6-4d4e-8114-ab9e843cb52f-7380", "activationEvents": [ - "*" + "onCommand:queryStore.topResourceConsumingQueriesOpen", + "onCommand:queryStore.overallResourceConsumptionOpen" ], "main": "./out/extension", "repository": { @@ -31,6 +32,16 @@ ], "contributes": { "commands": [ + { + "command": "queryStore.topResourceConsumingQueriesOpen", + "title": "%queryStore.topResourceConsumingQueriesOpen%", + "category": "%queryStore.category%" + }, + { + "command": "queryStore.overallResourceConsumptionOpen", + "title": "%queryStore.overallResourceConsumptionOpen%", + "category": "%queryStore.category%" + } ], "menus": { "objectExplorer/item/context": [ diff --git a/extensions/query-store/package.nls.json b/extensions/query-store/package.nls.json index 82e3c1e362..c51a47d357 100644 --- a/extensions/query-store/package.nls.json +++ b/extensions/query-store/package.nls.json @@ -1,4 +1,7 @@ { "queryStore.displayName": "Query Store", - "queryStore.description": "Query Store extension for Azure Data Studio." + "queryStore.description": "Query Store extension for Azure Data Studio.", + "queryStore.category": "Query Store", + "queryStore.topResourceConsumingQueriesOpen": "Open Top Resource Consuming Queries", + "queryStore.overallResourceConsumptionOpen": "Open Overall Resource Consumption" } diff --git a/extensions/query-store/src/common/constants.ts b/extensions/query-store/src/common/constants.ts new file mode 100644 index 0000000000..d519f6d5a0 --- /dev/null +++ b/extensions/query-store/src/common/constants.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vscode-nls'; + +const localize = nls.loadMessageBundle(); + +export const overallResourceConsumption = localize('overallResourceConsumption', "Overall Resource Consumption"); +export const duration = localize('duration', "Duration"); +export const executionCount = localize('executionCount', "Execution Count"); +export const cpuTime = localize('cpuTime', "CPU Time"); +export const logicalReads = localize('logicalReads', "Logical Reads"); +export function overallResourceConsumptionToolbarLabel(databaseName: string): string { return localize('overallResourceConsumptionToolbarLabel', "Overall resource consumption for database {0}", databaseName); } + +export const topResourceConsumingQueries = localize('topResourceConsumingQueries', "Top Resource Consuming Queries"); +export const queries = localize('queries', "Queries"); +export function planSummary(queryId: string): string { return localize('planSummary', "Plan Summary for query {0}", queryId); } +export function plan(queryId: string): string { return localize('plan', "Plan {0}", queryId); } +export function topResourceConsumingQueriesToolbarLabel(databaseName: string): string { return localize('topResourceConsumingQueriesToolbarLabel', "Top 25 resource consumers for database {0}", databaseName); } + +export const configure = localize('configure', "Configure"); diff --git a/extensions/query-store/src/common/utils.ts b/extensions/query-store/src/common/utils.ts new file mode 100644 index 0000000000..94120c6e9b --- /dev/null +++ b/extensions/query-store/src/common/utils.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; + +/** + * Creates a flex container with the provided component and sets the background color + * TODO: Remove/redo this helper function after chart components are hooked up, when background color is no longer used + * @param view + * @param component + * @param backgroundColor + * @returns Flex container with the specified background color containing component + */ +export async function createOneComponentFlexContainer(view: azdata.ModelView, component: azdata.Component, backgroundColor: string): Promise { + const flexContainer = view.modelBuilder.flexContainer().component(); + + await flexContainer.updateCssStyles({ 'background-color': backgroundColor }); + + flexContainer.addItem(component); + + flexContainer.setLayout({ + width: '100%', + height: '100%' + }); + + return flexContainer; +} + +/** + * Creates a flex container with two components, either horizontally or vertically based on the passed in flexFlow + * @param view + * @param firstComponent + * @param secondComponent + * @param flexFlow row or column + * @returns Flex container containing the two components + */ +export async function createTwoComponentFlexContainer(view: azdata.ModelView, firstComponent: azdata.Component, secondComponent: azdata.Component, flexFlow: string): Promise { + const flexContainer = view.modelBuilder.flexContainer().component(); + + if (flexFlow === 'row') { + flexContainer.addItems([firstComponent, secondComponent], { CSSStyles: { 'width': '50%' } }); + } else { + flexContainer.addItems([firstComponent, secondComponent], { CSSStyles: { 'height': '50%' } }); + } + + flexContainer.setLayout({ + flexFlow: flexFlow, + width: '100%', + height: '100%' + }); + + return flexContainer; +} + +/** + * Creates a vertical splitview + * @param view + * @param topComponent + * @param bottomComponent + * @param splitViewHeight + * @returns Vertical SplitViewContainer with the top and bottom components + */ +export function createVerticalSplitView(view: azdata.ModelView, topComponent: azdata.Component, bottomComponent: azdata.Component, splitViewHeight: number): azdata.SplitViewContainer { + // TODO: figure out why the horizontal spliview isn't working + + const splitview = view.modelBuilder.splitViewContainer().component(); + splitview.addItem(topComponent); + splitview.addItem(bottomComponent); + + splitview.setLayout({ + orientation: 'vertical', + splitViewHeight: splitViewHeight + }); + + return splitview; +} diff --git a/extensions/query-store/src/extension.ts b/extensions/query-store/src/extension.ts index d09c0499de..7d54b63a08 100644 --- a/extensions/query-store/src/extension.ts +++ b/extensions/query-store/src/extension.ts @@ -4,8 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { TopResourceConsumingQueries } from './reports/topResourceConsumingQueries'; +import { OverallResourceConsumption } from './reports/overallResourceConsumption'; -export async function activate(_context: vscode.ExtensionContext): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { + // TODO: get db name + context.subscriptions.push(vscode.commands.registerCommand('queryStore.topResourceConsumingQueriesOpen', async () => { await new TopResourceConsumingQueries(context, 'WideWorldImporters').open() })); + context.subscriptions.push(vscode.commands.registerCommand('queryStore.overallResourceConsumptionOpen', async () => { await new OverallResourceConsumption(context, 'WideWorldImporters').open() })); } export function deactivate(): void { diff --git a/extensions/query-store/src/reports/baseQueryStoreReport.ts b/extensions/query-store/src/reports/baseQueryStoreReport.ts new file mode 100644 index 0000000000..8916c62c1a --- /dev/null +++ b/extensions/query-store/src/reports/baseQueryStoreReport.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; +import * as path from 'path'; +import * as utils from '../common/utils'; +import * as constants from '../common/constants'; + +export abstract class BaseQueryStoreReport { + protected editor: azdata.workspace.ModelViewEditor; + protected flexModel?: azdata.FlexContainer; + + constructor(reportName: string, private reportTitle: string, protected resizeable: boolean, private extensionContext: vscode.ExtensionContext) { + this.editor = azdata.workspace.createModelViewEditor(reportName, { retainContextWhenHidden: true, supportsSave: false }, reportName); + } + + /** + * Creates and opens the report + */ + public async open(): Promise { + this.editor.registerContent(async (view) => { + this.flexModel = view.modelBuilder.flexContainer().component(); + + const toolbar = await this.createToolbar(view); + this.flexModel.addItem(toolbar, { flex: 'none' }); + + const views = await this.createViews(view); + + const mainContainer = await this.createMainContainer(view, views); + + this.flexModel.addItem(mainContainer, { CSSStyles: { 'width': '100%', 'height': '100%' } }); + + this.flexModel.setLayout({ + flexFlow: 'column', + height: '100%' + }); + + await view.initializeModel(this.flexModel); + }); + + await this.editor.openEditor(); + } + + /** + * Creates the main container containing the different components of the report + * @param view + * @param containers Array of containers to add to the main container + * @returns FlexContainer or SplitViewContainer containing the containers + */ + private async createMainContainer(view: azdata.ModelView, containers: azdata.FlexContainer[]): Promise { + let mainContainer; + + switch (containers.length) { + case 1: { + mainContainer = containers[0]; + break; + } + case 2: { + // TODO: replace 800 to have the number be based on how big the window is + // one container on top, one on the bottom + mainContainer = this.resizeable ? utils.createVerticalSplitView(view, containers[0], containers[1], 800) : await utils.createTwoComponentFlexContainer(view, containers[0], containers[1], 'column'); + break; + } case 3: { + // 2 containers on top, one on the bottom + // TODO: support portrait and landscape view. Right now it's landscape view only + mainContainer = this.resizeable ? utils.createVerticalSplitView(view, await utils.createTwoComponentFlexContainer(view, containers[0], containers[1], 'row'), containers[2], 800) + : await utils.createTwoComponentFlexContainer(view, await utils.createTwoComponentFlexContainer(view, containers[0], containers[1], 'row'), containers[2], 'column'); + break; + } case 4: { + // 2 containers on top, 2 on the bottom + mainContainer = this.resizeable ? utils.createVerticalSplitView(view, await utils.createTwoComponentFlexContainer(view, containers[0], containers[1], 'row'), await utils.createTwoComponentFlexContainer(view, containers[2], containers[3], 'row'), 800) + : await utils.createTwoComponentFlexContainer(view, await utils.createTwoComponentFlexContainer(view, containers[0], containers[1], 'row'), await utils.createTwoComponentFlexContainer(view, containers[2], containers[3], 'row'), 'column'); + break; + } default: { + throw new Error(`{views.length} number of views in a QDS report is not supported`); + } + } + + return mainContainer + } + + /** + * Creates the toolbar for the overall report with the report title, time range, and configure button + * @param view + */ + protected async createToolbar(view: azdata.ModelView): Promise { + const toolBar = view.modelBuilder.toolbarContainer().withProps({ + CSSStyles: { 'padding': '5px' } + }); + + const reportTitle = view.modelBuilder.text().withProps({ + value: this.reportTitle, + title: this.reportTitle, + CSSStyles: { 'margin-top': '5px', 'margin-bottom': '5px', 'margin-right': '15px' } + }).component(); + + // TODO: get time from configuration dialog + const timePeriod = view.modelBuilder.text().withProps({ + // placeholder times + value: 'Time period: 5/15/2023 11:58 AM - 5/23/2023 11:58 AM', + title: 'Time period: 5/15/2023 11:58 AM - 5/23/2023 11:58 AM', + CSSStyles: { 'margin-top': '5px', 'margin-bottom': '5px', 'margin-right': '15px' } + }).component(); + + const configureButton = view.modelBuilder.button().withProps({ + label: constants.configure, + title: constants.configure, + iconPath: { + light: path.join(this.extensionContext.extensionPath, 'images', 'light', 'gear.svg'), + dark: path.join(this.extensionContext.extensionPath, 'images', 'dark', 'gear.svg') + } + }).component(); + + // TODO: enable after the configuration dialog is implemented + configureButton.enabled = false; + + configureButton.onDidClick(() => { + // TODO: implement configuration dialog + console.error('configuration dialog not implemented') + }); + + await configureButton.updateCssStyles({ 'margin-top': '5px' }); + + toolBar.addToolbarItems([ + { + component: reportTitle, + toolbarSeparatorAfter: true + }, + { + component: timePeriod, + toolbarSeparatorAfter: true + }, + { + component: configureButton + } + ]); + + return toolBar.component(); + } + + protected abstract createViews(_view: azdata.ModelView): Promise; +} + diff --git a/extensions/query-store/src/reports/overallResourceConsumption.ts b/extensions/query-store/src/reports/overallResourceConsumption.ts new file mode 100644 index 0000000000..9d67ef4c69 --- /dev/null +++ b/extensions/query-store/src/reports/overallResourceConsumption.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as constants from '../common/constants'; +import { BaseQueryStoreReport } from './baseQueryStoreReport'; +import { QueryStoreView } from './queryStoreView'; + + +export class OverallResourceConsumption extends BaseQueryStoreReport { + private duration: QueryStoreView; + private executionCount: QueryStoreView; + private cpuTime: QueryStoreView; + private logicalReads: QueryStoreView; + + constructor(extensionContext: vscode.ExtensionContext, databaseName: string) { + super(constants.overallResourceConsumption, constants.overallResourceConsumptionToolbarLabel(databaseName), /*resizeable*/ false, extensionContext); + this.duration = new QueryStoreView(constants.duration, 'chartreuse'); + this.executionCount = new QueryStoreView(constants.executionCount, 'coral'); + this.cpuTime = new QueryStoreView(constants.cpuTime, 'darkturquoise'); + this.logicalReads = new QueryStoreView(constants.logicalReads, 'forestgreen'); + } + + public override async createViews(view: azdata.ModelView): Promise { + const durationContainer = await this.duration.createViewContainer(view); + const executionCountContainer = await this.executionCount.createViewContainer(view); + const cpuTimeContainer = await this.cpuTime.createViewContainer(view); + const logicalReadsContainer = await this.logicalReads.createViewContainer(view); + + return [durationContainer, executionCountContainer, cpuTimeContainer, logicalReadsContainer]; + } +} diff --git a/extensions/query-store/src/reports/queryStoreView.ts b/extensions/query-store/src/reports/queryStoreView.ts new file mode 100644 index 0000000000..66ae39f284 --- /dev/null +++ b/extensions/query-store/src/reports/queryStoreView.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { createOneComponentFlexContainer } from '../common/utils'; + +/** + * Defines a view in a query store report + */ +export class QueryStoreView { + // TODO: add toolbar support + // TODO: add support for toggling between chart and table components (could potentially add a child class to support this). + public component?: azdata.Component; // chart, query plan, text (component to display whole query text) + + /** + * + * @param title Title of view to display + * @param backgroundColor TODO: remove this after chart components are supported + */ + constructor(private title: string, private backgroundColor: string) { } + + /** + * Creates component in a container with the background color. Eventually will create the component with a toolbar in a flex container + * @param view + * @returns + */ + public async createViewContainer(view: azdata.ModelView): Promise { + // TODO: replace these text components with the actual chart/table/query plan components + this.component = view.modelBuilder.text().withProps({ + value: this.title + }).component(); + + return await createOneComponentFlexContainer(view, this.component, this.backgroundColor); + } +} diff --git a/extensions/query-store/src/reports/topResourceConsumingQueries.ts b/extensions/query-store/src/reports/topResourceConsumingQueries.ts new file mode 100644 index 0000000000..59b17f3c34 --- /dev/null +++ b/extensions/query-store/src/reports/topResourceConsumingQueries.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as constants from '../common/constants'; +import { BaseQueryStoreReport } from './baseQueryStoreReport'; +import { QueryStoreView } from './queryStoreView'; + +export class TopResourceConsumingQueries extends BaseQueryStoreReport { + private queries: QueryStoreView; + private planSummary: QueryStoreView; + private plan: QueryStoreView; + + constructor(extensionContext: vscode.ExtensionContext, databaseName: string) { + super(constants.topResourceConsumingQueries, constants.topResourceConsumingQueriesToolbarLabel(databaseName), /*resizeable*/ true, extensionContext); + this.queries = new QueryStoreView(constants.queries, 'chartreuse'); + this.planSummary = new QueryStoreView(constants.planSummary('x'), 'coral'); // TODO: replace 'x' with actual query id + this.plan = new QueryStoreView(constants.plan('x'), 'darkturquoise'); + } + + public override async createViews(view: azdata.ModelView): Promise { + const queriesContainer = await this.queries.createViewContainer(view); + const planSummaryContainer = await this.planSummary.createViewContainer(view); + const planContainer = await this.plan.createViewContainer(view); + + return [queriesContainer, planSummaryContainer, planContainer]; + } +}