From 21016c1a5329bca3d47b061c2245e1bf0d1943de Mon Sep 17 00:00:00 2001 From: Kim Santiago <31145923+kisantia@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:13:08 -0700 Subject: [PATCH] add button to open query store report in new tab (#24303) * add button to open query store report in new tab * addressing comments --- .../images/dark/multiple-windows.svg | 3 ++ .../images/light/multiple-windows.svg | 3 ++ .../query-store/src/common/constants.ts | 5 +++ .../query-store/src/common/iconHelper.ts | 40 +++++++++++++++++++ extensions/query-store/src/common/promise.ts | 25 ++++++++++++ extensions/query-store/src/extension.ts | 12 +++++- .../src/reports/baseQueryStoreReport.ts | 33 ++++++++++++--- .../src/reports/overallResourceConsumption.ts | 5 +-- .../src/reports/queryStoreDashboard.ts | 31 +++++++++----- .../reports/topResourceConsumingQueries.ts | 5 +-- 10 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 extensions/query-store/images/dark/multiple-windows.svg create mode 100644 extensions/query-store/images/light/multiple-windows.svg create mode 100644 extensions/query-store/src/common/iconHelper.ts create mode 100644 extensions/query-store/src/common/promise.ts diff --git a/extensions/query-store/images/dark/multiple-windows.svg b/extensions/query-store/images/dark/multiple-windows.svg new file mode 100644 index 0000000000..f667cc0580 --- /dev/null +++ b/extensions/query-store/images/dark/multiple-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/query-store/images/light/multiple-windows.svg b/extensions/query-store/images/light/multiple-windows.svg new file mode 100644 index 0000000000..2859a0af88 --- /dev/null +++ b/extensions/query-store/images/light/multiple-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/query-store/src/common/constants.ts b/extensions/query-store/src/common/constants.ts index 0b1a9db6a9..97b618193a 100644 --- a/extensions/query-store/src/common/constants.ts +++ b/extensions/query-store/src/common/constants.ts @@ -9,6 +9,10 @@ const localize = nls.loadMessageBundle(); export function queryStoreDashboardTitle(databaseName: string): string { return localize('queryStoreDashboardTitle', "Query Store - {0}", databaseName); } +// report dashboard tab ids +export const overallResourceConsumptionTabId = 'OverallResourceConsumptionTab'; +export const topResourceConsumingQueriesTabId = 'TopResourceConsumingQueriesTab'; + export const overallResourceConsumption = localize('overallResourceConsumption', "Overall Resource Consumption"); export const duration = localize('duration', "Duration"); export const executionCount = localize('executionCount', "Execution Count"); @@ -23,6 +27,7 @@ export function plan(queryId: string): string { return localize('plan', "Plan {0 export function topResourceConsumingQueriesToolbarLabel(databaseName: string): string { return localize('topResourceConsumingQueriesToolbarLabel', "Top 25 resource consumers for database {0}", databaseName); } export const configure = localize('configure', "Configure"); +export const openInNewTab = localize('openInNewTab', "Open In New Tab"); export const okButtonText = localize('okButtonText', "Ok"); export const cancelButtonText = localize('cancelButtonText', "Cancel"); export const applyButtonText = localize('applyButtonText', "Apply"); diff --git a/extensions/query-store/src/common/iconHelper.ts b/extensions/query-store/src/common/iconHelper.ts new file mode 100644 index 0000000000..e2622592b4 --- /dev/null +++ b/extensions/query-store/src/common/iconHelper.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export interface IconPath { + dark: string; + light: string; +} + +export class IconPathHelper { + private static extensionContext: vscode.ExtensionContext; + public static multipleWindows: IconPath; + public static gear: IconPath; + + public static setExtensionContext(extensionContext: vscode.ExtensionContext) { + IconPathHelper.extensionContext = extensionContext; + + IconPathHelper.gear = IconPathHelper.makeIcon('gear'); + IconPathHelper.multipleWindows = IconPathHelper.makeIcon('multiple-windows'); + } + + private static makeIcon(name: string, sameIcon: boolean = false) { + const folder = 'images'; + + if (sameIcon) { + return { + dark: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/${name}.svg`), + light: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/${name}.svg`) + }; + } else { + return { + dark: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/dark/${name}.svg`), + light: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/light/${name}.svg`) + }; + } + } +} diff --git a/extensions/query-store/src/common/promise.ts b/extensions/query-store/src/common/promise.ts new file mode 100644 index 0000000000..ab8e6c3284 --- /dev/null +++ b/extensions/query-store/src/common/promise.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Deferred promise + */ +export class Deferred { + promise: Promise; + resolve!: (value: T | PromiseLike) => void; + reject!: (reason?: any) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + then(onFulfilled?: (value: T) => TResult | Thenable, onRejected?: (reason: any) => TResult | Thenable): Thenable; + then(onFulfilled?: (value: T) => TResult | Thenable, onRejected?: (reason: any) => void): Thenable; + then(onFulfilled?: (value: T) => TResult | Thenable, onRejected?: (reason: any) => TResult | Thenable): Thenable { + return this.promise.then(onFulfilled, onRejected); + } +} diff --git a/extensions/query-store/src/extension.ts b/extensions/query-store/src/extension.ts index 77c6e16835..2f9fd0bc4d 100644 --- a/extensions/query-store/src/extension.ts +++ b/extensions/query-store/src/extension.ts @@ -5,12 +5,22 @@ import * as vscode from 'vscode'; import { QueryStoreDashboard } from './reports/queryStoreDashboard'; +import { IconPathHelper } from './common/iconHelper'; export async function activate(context: vscode.ExtensionContext): Promise { // TODO: get db name // TODO: add OE entry point with condition for command to only be visible for db's with Query Store enabled (or consider always showing and having a way to enable when dashboard is opened?) // TODO: remove entry point from command palette - keeping for now to speed up testing so a connection doesn't need to be made to launch the dashboard - context.subscriptions.push(vscode.commands.registerCommand('queryStore.openQueryStoreDashboard', async () => { await new QueryStoreDashboard('AdventureWorks', context).open() })); + context.subscriptions.push(vscode.commands.registerCommand('queryStore.openQueryStoreDashboard', async (targetTab?: string) => { + IconPathHelper.setExtensionContext(context); + + const dashboard = new QueryStoreDashboard('AdventureWorks') + await dashboard.open(); + + if (targetTab) { + dashboard.selectTab(targetTab); + } + })); } export function deactivate(): void { diff --git a/extensions/query-store/src/reports/baseQueryStoreReport.ts b/extensions/query-store/src/reports/baseQueryStoreReport.ts index 69d7b3cfe5..ccc479633a 100644 --- a/extensions/query-store/src/reports/baseQueryStoreReport.ts +++ b/extensions/query-store/src/reports/baseQueryStoreReport.ts @@ -5,17 +5,23 @@ 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'; import { ConfigureDialog } from '../settings/configureDialog'; +import { IconPathHelper } from '../common/iconHelper'; export abstract class BaseQueryStoreReport { protected flexModel?: azdata.FlexContainer; protected configureDialog?: ConfigureDialog; protected configureButton?: azdata.ButtonComponent; - constructor(private reportTitle: string, protected resizeable: boolean, private extensionContext: vscode.ExtensionContext) { } + /** + * Constructor + * @param reportTitle Title of report shown in toolbar + * @param reportId Id of tab used in query store dashboard + * @param resizeable Whether or not the sections of the report are resizeable + */ + constructor(private reportTitle: string, private reportId: string, protected resizeable: boolean) { } public get ReportContent(): azdata.FlexContainer | undefined { return this.flexModel; @@ -101,13 +107,25 @@ export abstract class BaseQueryStoreReport { CSSStyles: { 'margin-top': '5px', 'margin-bottom': '5px', 'margin-right': '15px' } }).component(); + // Open in New Tab button + const openInNewTabButton = view.modelBuilder.button().withProps({ + label: constants.openInNewTab, + title: constants.openInNewTab, + iconPath: IconPathHelper.multipleWindows + }).component(); + openInNewTabButton.enabled = true; + + openInNewTabButton.onDidClick(async () => { + await vscode.commands.executeCommand('queryStore.openQueryStoreDashboard', this.reportId); + }); + + await openInNewTabButton.updateCssStyles({ 'margin-top': '5px' }); + + // Configure button this.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') - } + iconPath: IconPathHelper.gear }).component(); this.configureButton.enabled = true; @@ -127,6 +145,9 @@ export abstract class BaseQueryStoreReport { component: timePeriod, toolbarSeparatorAfter: true }, + { + component: openInNewTabButton + }, { component: this.configureButton } diff --git a/extensions/query-store/src/reports/overallResourceConsumption.ts b/extensions/query-store/src/reports/overallResourceConsumption.ts index f32446b989..53af7ae31c 100644 --- a/extensions/query-store/src/reports/overallResourceConsumption.ts +++ b/extensions/query-store/src/reports/overallResourceConsumption.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; -import * as vscode from 'vscode'; import * as constants from '../common/constants'; import { BaseQueryStoreReport } from './baseQueryStoreReport'; import { QueryStoreView } from './queryStoreView'; @@ -18,8 +17,8 @@ export class OverallResourceConsumption extends BaseQueryStoreReport { private cpuTime: QueryStoreView; private logicalReads: QueryStoreView; - constructor(extensionContext: vscode.ExtensionContext, databaseName: string) { - super(constants.overallResourceConsumptionToolbarLabel(databaseName), /*resizeable*/ false, extensionContext); + constructor(databaseName: string) { + super(constants.overallResourceConsumptionToolbarLabel(databaseName), constants.overallResourceConsumptionTabId,/*resizeable*/ false); this.duration = new QueryStoreView(constants.duration, 'chartreuse'); this.executionCount = new QueryStoreView(constants.executionCount, 'coral'); this.cpuTime = new QueryStoreView(constants.cpuTime, 'darkturquoise'); diff --git a/extensions/query-store/src/reports/queryStoreDashboard.ts b/extensions/query-store/src/reports/queryStoreDashboard.ts index 9e66044af2..6827ea918e 100644 --- a/extensions/query-store/src/reports/queryStoreDashboard.ts +++ b/extensions/query-store/src/reports/queryStoreDashboard.ts @@ -3,46 +3,57 @@ * 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 constants from '../common/constants'; import { TopResourceConsumingQueries } from './topResourceConsumingQueries'; import { OverallResourceConsumption } from './overallResourceConsumption'; +import { Deferred } from '../common/promise'; export class QueryStoreDashboard { - constructor(private dbName: string, private extensionContext: vscode.ExtensionContext) { } + private dashboard?: azdata.window.ModelViewDashboard; + private initDashboardComplete: Deferred = new Deferred(); + + constructor(private dbName: string) { } /** * Creates and opens the report */ public async open(): Promise { // TODO: update title based on selected tab to have the current selected report in editor tab title - const dashboard = azdata.window.createModelViewDashboard(constants.queryStoreDashboardTitle(this.dbName)); - dashboard.registerTabs(async (view: azdata.ModelView) => { - const topResourceConsumingQueriesReport = new TopResourceConsumingQueries(this.extensionContext, this.dbName); - const overallResourceConsumptionReport = new OverallResourceConsumption(this.extensionContext, this.dbName); + this.dashboard = azdata.window.createModelViewDashboard(constants.queryStoreDashboardTitle(this.dbName)); + this.dashboard.registerTabs(async (view: azdata.ModelView) => { + const topResourceConsumingQueriesReport = new TopResourceConsumingQueries(this.dbName); + const overallResourceConsumptionReport = new OverallResourceConsumption(this.dbName); await Promise.all([topResourceConsumingQueriesReport.createReport(view), overallResourceConsumptionReport.createReport(view)]); const topResourceConsumingQueriesTab: azdata.DashboardTab = { - id: 'TopResourceConsumingQueriesTab', + id: constants.topResourceConsumingQueriesTabId, content: topResourceConsumingQueriesReport.ReportContent!, title: constants.topResourceConsumingQueries }; const overallResourceConsumptionTab: azdata.DashboardTab = { - id: 'OverallResourceConsumptionTab', + id: constants.overallResourceConsumptionTabId, content: overallResourceConsumptionReport.ReportContent!, title: constants.overallResourceConsumption }; + this.initDashboardComplete?.resolve(); + return [ overallResourceConsumptionTab, topResourceConsumingQueriesTab ]; }); - await dashboard.open(); + await this.dashboard.open(); + await this.initDashboardComplete; + } + + public selectTab(selectedTab: string): void { + // TODO: fix flashing - currently starts with the first tab selected, then switches to the other tab. Ideally would be able to set the + // selected tab when registering the tabs + this.dashboard?.selectTab(selectedTab); } } - diff --git a/extensions/query-store/src/reports/topResourceConsumingQueries.ts b/extensions/query-store/src/reports/topResourceConsumingQueries.ts index 95c2479f3c..87bd20b2ae 100644 --- a/extensions/query-store/src/reports/topResourceConsumingQueries.ts +++ b/extensions/query-store/src/reports/topResourceConsumingQueries.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; -import * as vscode from 'vscode'; import * as constants from '../common/constants'; import { BaseQueryStoreReport } from './baseQueryStoreReport'; import { QueryStoreView } from './queryStoreView'; @@ -16,8 +15,8 @@ export class TopResourceConsumingQueries extends BaseQueryStoreReport { private planSummary: QueryStoreView; private plan: QueryStoreView; - constructor(extensionContext: vscode.ExtensionContext, databaseName: string) { - super(constants.topResourceConsumingQueriesToolbarLabel(databaseName), /*resizeable*/ true, extensionContext); + constructor(databaseName: string) { + super(constants.topResourceConsumingQueriesToolbarLabel(databaseName), constants.topResourceConsumingQueriesTabId,/*resizeable*/ true); 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');