From dba5880f35b50f6d12f093b62f023bad52f10a0d Mon Sep 17 00:00:00 2001 From: nasc17 <69922333+nasc17@users.noreply.github.com> Date: Mon, 8 Mar 2021 10:05:11 -0800 Subject: [PATCH] Postgres Resource Health Paage (#14575) * Add podstatus to spec * Added image to table and fixed spacing. * Added pod status to spec * PR fixes * Added resource health page, created overiew box * Pod condtion table is up * Tryingt to fix how table refreshes * Fixed how drop down changes table * Overview box shows number of running and pending pods * overview box refresh fix * Updated summary section * PR fixes * Condensed create pod list function * Added enum * fixed refresh * Fixed refresh, fixed if all availble section add --- extensions/arc/src/localizedConstants.ts | 15 +- .../dashboards/postgres/postgresDashboard.ts | 3 + .../postgres/postgresResourceHealthPage.ts | 335 ++++++++++++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 extensions/arc/src/ui/dashboards/postgres/postgresResourceHealthPage.ts diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index cc18b19974..0821cfa568 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -134,6 +134,8 @@ export const postgresArcProductName = localize('arc.postgresArcProductName', "Az export const coordinator = localize('arc.coordinator', "Coordinator"); export const worker = localize('arc.worker', "Worker"); export const monitor = localize('arc.monitor', "Monitor"); +export const available = localize('arc.available', "Available"); +export const issuesDetected = localize('arc.issuesDetected', "Issues Detected"); export const newDatabase = localize('arc.newDatabase', "New Database"); export const databaseName = localize('arc.databaseName', "Database name"); export const enterNewPassword = localize('arc.enterNewPassword', "Enter a new password"); @@ -152,6 +154,7 @@ export const postgresComputeAndStorageDescriptionPartTwo = localize('arc.postgre export const computeAndStorageDescriptionPartThree = localize('arc.computeAndStorageDescriptionPartThree', "without downtime and by"); export const computeAndStorageDescriptionPartFour = localize('arc.computeAndStorageDescriptionPartFour', "Before doing so, you need to ensure"); export const computeAndStorageDescriptionPartFive = localize('arc.computeAndStorageDescriptionPartFive', "there are sufficient resources available"); +export const resourceHealthDescription = localize('arc.resourceHealthDescription', "Resource health can tell you if your resource is running as expected."); export const computeAndStorageDescriptionPartSix = localize('arc.computeAndStorageDescriptionPartSix', "in your Kubernetes cluster to honor this configuration."); export const node = localize('arc.node', "node"); export const nodes = localize('arc.nodes', "nodes"); @@ -169,14 +172,21 @@ export const arcResources = localize('arc.arcResources', "Azure Arc Resources"); export const enterANonEmptyPassword = localize('arc.enterANonEmptyPassword', "Enter a non empty password or press escape to exit."); export const thePasswordsDoNotMatch = localize('arc.thePasswordsDoNotMatch', "The passwords do not match. Confirm the password or press escape to exit."); export const passwordReset = localize('arc.passwordReset', "Password reset successfully"); -export const podOverview = localize('arc.podOverview', "Pod overview"); export const condition = localize('arc.condition', "Condition"); export const details = localize('arc.details', "Details"); -export const lastUpdated = localize('arc.lastUpdated', "Last updated"); +export const lastTransition = localize('arc.lastTransition', "Last transition"); export const noExternalEndpoint = localize('arc.noExternalEndpoint', "No External Endpoint has been configured so this information isn't available."); export const podsReady = localize('arc.podsReady', "pods ready"); +export const podsPresent = localize('arc.podsPresent', "Pods Present"); +export const podsUsedDescription = localize('arc.podsUsedDescription', "Select a pod in the dropdown below for detailed health information."); export const connectToPostgresDescription = localize('arc.connectToPostgresDescription', "A connection to the server is required to show and set database engine settings, which will require the PostgreSQL Extension to be installed."); export const postgresExtension = localize('arc.postgresExtension', "microsoft.azuredatastudio-postgresql"); +export const podInitialized = localize('arc.podInitialized', "Pod is initialized."); +export const podReady = localize('arc.podReady', "Pod is ready."); +export const noPodIssuesDetected = localize('arc.noPodIssuesDetected', "There aren’t any known issues affecting this PostgreSQL Hyperscale instance."); +export const podIssuesDetected = localize('arc.podIssuesDetected', "The pods listed below are experiencing issues that may affect performance or availability."); +export const containerReady = localize('arc.containerReady', "Pod containers are ready."); +export const podScheduled = localize('arc.podScheduled', "Pod is schedulable."); export function rangeSetting(min: string, max: string): string { return localize('arc.rangeSetting', "Value is expected to be in the range {0} - {1}", min, max); } export function databaseCreated(name: string): string { return localize('arc.databaseCreated', "Database {0} created", name); } @@ -225,6 +235,7 @@ export function fetchEndpointsFailed(name: string, error: any): string { return export function fetchRegistrationsFailed(name: string, error: any): string { return localize('arc.fetchRegistrationsFailed', "An unexpected error occurred retrieving the registrations for '{0}'. {1}", name, getErrorMessage(error)); } export function fetchDatabasesFailed(name: string, error: any): string { return localize('arc.fetchDatabasesFailed', "An unexpected error occurred retrieving the databases for '{0}'. {1}", name, getErrorMessage(error)); } export function fetchEngineSettingsFailed(name: string, error: any): string { return localize('arc.fetchEngineSettingsFailed', "An unexpected error occurred retrieving the engine settings for '{0}'. {1}", name, getErrorMessage(error)); } +export function numberOfIssuesDetected(name: string, issues: number): string { return localize('arc.numberOfIssuesDetected', "• {0} ({1} issues)", name, issues); } export function instanceDeletionWarning(name: string): string { return localize('arc.instanceDeletionWarning', "Warning! Deleting an instance is permanent and cannot be undone. To delete the instance '{0}' type the name '{0}' below to proceed.", name); } export function invalidInstanceDeletionName(name: string): string { return localize('arc.invalidInstanceDeletionName', "The value '{0}' does not match the instance name. Try again or press escape to exit", name); } export function couldNotFindAzureResource(name: string): string { return localize('arc.couldNotFindAzureResource', "Could not find Azure resource for {0}", name); } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts index ab3ebe9d9c..505adeb8fb 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts @@ -16,6 +16,7 @@ import { PostgresSupportRequestPage } from './postgresSupportRequestPage'; import { PostgresComputeAndStoragePage } from './postgresComputeAndStoragePage'; import { PostgresWorkerNodeParametersPage } from './postgresWorkerNodeParametersPage'; import { PostgresPropertiesPage } from './postgresPropertiesPage'; +import { PostgresResourceHealthPage } from './postgresResourceHealthPage'; export class PostgresDashboard extends Dashboard { constructor(private _context: vscode.ExtensionContext, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { @@ -40,6 +41,7 @@ export class PostgresDashboard extends Dashboard { const workerNodeParametersPage = new PostgresWorkerNodeParametersPage(modelView, this._postgresModel); const diagnoseAndSolveProblemsPage = new PostgresDiagnoseAndSolveProblemsPage(modelView, this._context, this._postgresModel); const supportRequestPage = new PostgresSupportRequestPage(modelView, this._controllerModel, this._postgresModel); + const resourceHealthPage = new PostgresResourceHealthPage(modelView, this._postgresModel); return [ overviewPage.tab, @@ -55,6 +57,7 @@ export class PostgresDashboard extends Dashboard { { title: loc.supportAndTroubleshooting, tabs: [ + resourceHealthPage.tab, diagnoseAndSolveProblemsPage.tab, supportRequestPage.tab ] diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresResourceHealthPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresResourceHealthPage.ts new file mode 100644 index 0000000000..1340a3dc78 --- /dev/null +++ b/extensions/arc/src/ui/dashboards/postgres/postgresResourceHealthPage.ts @@ -0,0 +1,335 @@ +/*--------------------------------------------------------------------------------------------- + * 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 loc from '../../../localizedConstants'; +import { IconPathHelper, cssStyles, iconSize } from '../../../constants'; +import { DashboardPage } from '../../components/dashboardPage'; +import { PostgresModel } from '../../../models/postgresModel'; + +export type PodHealthModel = { + condition: string, + details?: azdata.Component, + lastUpdate: string +}; + +export enum PodCondtionType { + initialized = 'Initialized', + ready = 'Ready', + containersReady = 'ContainersReady', + podScheduled = 'PodScheduled' +} + +export class PostgresResourceHealthPage extends DashboardPage { + private podSummaryContainer!: azdata.DivContainer; + + private podConditionsContainer!: azdata.DivContainer; + private podConditionsLoading!: azdata.LoadingComponent; + private podConditionsTable!: azdata.DeclarativeTableComponent; + private podConditionsTableIndexes: Map = new Map(); + + private podDropDown!: azdata.DropDownComponent; + private coordinatorPodName!: string; + private coordinatorData: PodHealthModel[] = []; + private podsData: PodHealthModel[] = []; + + constructor(protected modelView: azdata.ModelView, private _postgresModel: PostgresModel) { + super(modelView); + + this.disposables.push( + this._postgresModel.onConfigUpdated(() => this.eventuallyRunOnInitialized(() => this.handleConfigUpdated()))); + } + + protected get title(): string { + return loc.resourceHealth; + } + + protected get id(): string { + return 'postgres-resource-health'; + } + + protected get icon(): { dark: string; light: string; } { + return IconPathHelper.health; + } + + protected get container(): azdata.Component { + const root = this.modelView.modelBuilder.divContainer().component(); + const content = this.modelView.modelBuilder.divContainer().component(); + root.addItem(content, { CSSStyles: { 'margin': '10px 20px 0px 20px' } }); + + content.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.resourceHealth, + CSSStyles: { ...cssStyles.title } + }).component()); + + content.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.resourceHealthDescription, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component()); + + this.podSummaryContainer = this.modelView.modelBuilder.divContainer().component(); + + this.refreshPodSummarySection(); + + content.addItem(this.podSummaryContainer); + + // Pod Conditions + content.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.podsPresent, + CSSStyles: { ...cssStyles.title } + }).component()); + + content.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.podsUsedDescription, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px', 'margin-top': '10px' } + }).component()); + + this.podConditionsContainer = this.modelView.modelBuilder.divContainer().component(); + this.podConditionsTable = this.modelView.modelBuilder.declarativeTable().withProps({ + width: '100%', + columns: [ + { + displayName: loc.condition, + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '20%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow + }, + { + displayName: loc.details, + valueType: azdata.DeclarativeDataType.component, + isReadOnly: true, + width: '50%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: { + ...cssStyles.tableRow, + 'min-width': '150px' + } + }, + { + displayName: loc.lastTransition, + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '30%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow + } + ], + data: [this.coordinatorData.map(p => [p.condition, p.details, p.lastUpdate])] + }).component(); + + this.podDropDown = this.modelView.modelBuilder.dropDown().withProps({ width: '150px' }).component(); + this.disposables.push( + this.podDropDown.onValueChanged(() => { + this.podConditionsTable.setFilter(this.podConditionsTableIndexes.get(String(this.podDropDown.value))); + }) + ); + + this.podConditionsContainer.addItem(this.podDropDown, { CSSStyles: { 'margin': '10px 0px 10px 0px' } }); + this.podConditionsContainer.addItem(this.podConditionsTable); + this.podConditionsLoading = this.modelView.modelBuilder.loadingComponent() + .withItem(this.podConditionsContainer) + .withProperties({ + loading: !this._postgresModel.configLastUpdated + }).component(); + + this.refreshPodCondtions(); + + content.addItem(this.podConditionsLoading, { CSSStyles: cssStyles.text }); + + this.initialized = true; + return root; + } + + protected get toolbarContainer(): azdata.ToolbarContainer { + // Refresh + const refreshButton = this.modelView.modelBuilder.button().withProps({ + label: loc.refresh, + iconPath: IconPathHelper.refresh + }).component(); + + this.disposables.push( + refreshButton.onDidClick(async () => { + refreshButton.enabled = false; + try { + this.podConditionsLoading!.loading = true; + await this._postgresModel.refresh(); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + finally { + refreshButton.enabled = true; + } + })); + + return this.modelView.modelBuilder.toolbarContainer().withToolbarItems([ + { component: refreshButton } + ]).component(); + } + + private createPodList(): string[] { + const podStatus = this._postgresModel.config?.status.podsStatus; + let podNames: string[] = []; + + podStatus?.forEach(p => { + let podHealthModels: PodHealthModel[] = []; + let indexes: number[] = []; + + + p.conditions.forEach(c => { + let message: string; + let imageComponent = this.modelView.modelBuilder.image().withProps({ + width: iconSize, + height: iconSize, + iconHeight: '15px', + iconWidth: '15px' + }).component(); + + if (c.status === 'False') { + imageComponent.iconPath = IconPathHelper.fail; + message = c.message ?? c.reason ?? ''; + } else { + imageComponent.iconPath = IconPathHelper.success; + + if (c.type === PodCondtionType.initialized) { + message = loc.podInitialized; + } else if (c.type === PodCondtionType.ready) { + message = loc.podReady; + } else if (c.type === PodCondtionType.containersReady) { + message = loc.containerReady; + } else if (c.type === PodCondtionType.podScheduled) { + message = loc.podScheduled; + } else { + message = c.message ?? c.reason ?? ''; + } + } + + const condtionContainer = this.modelView.modelBuilder.flexContainer().withProps({ + CSSStyles: { 'alignItems': 'center', 'height': '15px' } + }).component(); + condtionContainer.addItem(imageComponent, { CSSStyles: { 'margin-right': '0px' } }); + condtionContainer.addItem(this.modelView.modelBuilder.text().withProps({ + value: message, + }).component()); + + indexes.push(this.podsData.length); + this.podsData.push({ + condition: c.type, + details: condtionContainer, + lastUpdate: c.lastTransitionTime + }); + }); + + if (p.role.toUpperCase() !== loc.coordinator.toUpperCase()) { + podNames.push(p.name); + } else { + this.coordinatorData = podHealthModels; + this.coordinatorPodName = p.name; + podNames.unshift(p.name); + } + this.podConditionsTableIndexes.set(p.name, indexes); + }); + + this.podConditionsTable.data = this.podsData.map(p => [p.condition, p.details, p.lastUpdate]); + + return podNames; + } + + private findPodIssues(): string[] { + const podStatus = this._postgresModel.config?.status.podsStatus; + let issueCount = 0; + let podIssuesDetected: string[] = []; + + podStatus?.forEach(p => { + p.conditions.forEach(c => { + if (c.status === 'False') { + issueCount++; + } + }); + + if (issueCount > 0) { + podIssuesDetected.push(loc.numberOfIssuesDetected(p.name, issueCount)); + issueCount = 0; + } + }); + + return podIssuesDetected; + } + + private refreshPodSummarySection(): void { + let podSummaryTitle = this.modelView.modelBuilder.flexContainer().withProps({ + CSSStyles: { 'alignItems': 'center', 'height': '15px', 'margin-top': '20px' } + }).component(); + if (!this._postgresModel.config) { + podSummaryTitle.addItem(this.modelView.modelBuilder.loadingComponent().component(), { CSSStyles: { 'margin-right': '5px' } }); + podSummaryTitle.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.loading, + CSSStyles: { ...cssStyles.title } + }).component()); + this.podSummaryContainer.addItem(podSummaryTitle); + } else { + let components: azdata.Component[] = []; + let imageComponent = this.modelView.modelBuilder.image().withProps({ + iconPath: IconPathHelper.success, + width: iconSize, + height: iconSize, + iconHeight: '20px', + iconWidth: '20px' + }).component(); + + let podIssues = this.findPodIssues(); + if (podIssues.length === 0) { + imageComponent.iconPath = IconPathHelper.success; + podSummaryTitle.addItem(imageComponent, { CSSStyles: { 'margin-right': '5px' } }); + podSummaryTitle.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.available, + CSSStyles: { ...cssStyles.title, 'margin-left': '0px' } + }).component()); + components.push(podSummaryTitle); + components.push(this.modelView.modelBuilder.text().withProps({ + value: loc.noPodIssuesDetected, + CSSStyles: { ...cssStyles.text, 'margin-top': '20px' } + }).component()); + } else { + imageComponent.iconPath = IconPathHelper.fail; + podSummaryTitle.addItem(imageComponent, { CSSStyles: { 'margin-right': '5px' } }); + podSummaryTitle.addItem(this.modelView.modelBuilder.text().withProps({ + value: loc.issuesDetected, + CSSStyles: { ...cssStyles.title } + }).component()); + components.push(podSummaryTitle); + components.push(this.modelView.modelBuilder.text().withProps({ + value: loc.podIssuesDetected, + CSSStyles: { ...cssStyles.text, 'margin-top': '20px 0px 10px 0px' } + }).component()); + components.push(...podIssues.map(i => { + return this.modelView.modelBuilder.text().withProps({ + value: i, + CSSStyles: { ...cssStyles.text, 'margin': '0px' } + }).component(); + })); + } + this.podSummaryContainer.addItems(components); + } + } + + private refreshPodCondtions(): void { + if (this._postgresModel.config) { + this.podConditionsTableIndexes = new Map(); + this.podsData = []; + this.podDropDown.values = this.createPodList(); + this.podConditionsTable.setFilter(this.podConditionsTableIndexes.get(this.coordinatorPodName!)); + this.podConditionsLoading.loading = false; + } + } + + private handleConfigUpdated() { + this.podSummaryContainer.clearItems(); + this.refreshPodSummarySection(); + this.refreshPodCondtions(); + } +}