diff --git a/extensions/arc/images/refresh.svg b/extensions/arc/images/refresh.svg new file mode 100644 index 0000000000..f03579110b --- /dev/null +++ b/extensions/arc/images/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index 28d14c3123..e2b897135a 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -27,6 +27,7 @@ export class IconPathHelper { public static backup: IconPath; public static properties: IconPath; public static networking: IconPath; + public static refresh: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.context = context; @@ -86,6 +87,10 @@ export class IconPathHelper { light: context.asAbsolutePath('images/security.svg'), dark: context.asAbsolutePath('images/security.svg') }; + IconPathHelper.refresh = { + light: context.asAbsolutePath('images/refresh.svg'), + dark: context.asAbsolutePath('images/refresh.svg') + }; } } diff --git a/extensions/arc/src/extension.ts b/extensions/arc/src/extension.ts index a7dfa96414..e2cfb6040b 100644 --- a/extensions/arc/src/extension.ts +++ b/extensions/arc/src/extension.ts @@ -23,10 +23,19 @@ export async function activate(context: vscode.ExtensionContext): Promise const dbNamespace = ''; const dbName = ''; - const controllerModel = new ControllerModel(controllerUrl, auth); - const databaseModel = new PostgresModel(controllerUrl, auth, dbNamespace, dbName); - const postgresDashboard = new PostgresDashboard(loc.postgresDashboard, controllerModel, databaseModel); - await postgresDashboard.showDashboard(); + try { + const controllerModel = new ControllerModel(controllerUrl, auth); + const postgresModel = new PostgresModel(controllerUrl, auth, dbNamespace, dbName); + const postgresDashboard = new PostgresDashboard(loc.postgresDashboard, controllerModel, postgresModel); + + await Promise.all([ + postgresDashboard.showDashboard(), + controllerModel.refresh(), + postgresModel.refresh() + ]); + } catch (error) { + vscode.window.showErrorMessage(loc.failedToManagePostgres(`${dbNamespace}.${dbName}`, error)); + } }); } diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 5d6bd7a71b..dce75b36c4 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -46,6 +46,7 @@ export const feedback = localize('arc.feedback', 'Feedback'); export const selectConnectionString = localize('arc.selectConnectionString', 'Select from available client connection strings below'); export const vCores = localize('arc.vCores', 'vCores'); export const ram = localize('arc.ram', 'RAM'); +export const refresh = localize('arc.refresh', 'Refresh'); // Postgres constants export const coordinatorEndpoint = localize('arc.coordinatorEndpoint', 'Coordinator endpoint'); @@ -66,14 +67,16 @@ export const node = localize('arc.node', 'node'); export const nodes = localize('arc.nodes', 'nodes'); export const storagePerNode = localize('arc.storagePerNode', 'storage per node'); -export function databaseCreated(name: string): string { return localize('arc.databaseCreated', "Database '{0}' created", name); } -export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database '{0}'. {1}", name, (error instanceof Error ? error.message : error)); } -export function passwordReset(name: string): string { return localize('arc.passwordReset', "Password reset for service '{0}'", name); } -export function passwordResetFailed(name: string, error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password for service '{0}'. {1}", name, (error instanceof Error ? error.message : error)); } -export function deleteServicePrompt(name: string): string { return localize('arc.deleteServicePrompt', "Delete service '{0}'?", name); } -export function serviceDeleted(name: string): string { return localize('arc.serviceDeleted', "Service '{0}' deleted", name); } -export function serviceDeletionFailed(name: string, error: any): string { return localize('arc.serviceDeletionFailed', "Failed to delete service '{0}'. {1}", name, (error instanceof Error ? error.message : error)); } -export function couldNotFindAzureResource(name: string): string { return localize('arc.couldNotFindAzureResource', "Could not find Azure resource for '{0}'", name); } +export function databaseCreated(name: string): string { return localize('arc.databaseCreated', "Database {0} created", name); } +export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function passwordReset(name: string): string { return localize('arc.passwordReset', "Password reset for service {0}", name); } +export function passwordResetFailed(name: string, error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password for service {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function deleteServicePrompt(name: string): string { return localize('arc.deleteServicePrompt', "Delete service {0}?", name); } +export function serviceDeleted(name: string): string { return localize('arc.serviceDeleted', "Service {0} deleted", name); } +export function serviceDeletionFailed(name: string, error: any): string { return localize('arc.serviceDeletionFailed', "Failed to delete service {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function couldNotFindAzureResource(name: string): string { return localize('arc.couldNotFindAzureResource', "Could not find Azure resource for {0}", name); } export function copiedToClipboard(name: string): string { return localize('arc.copiedToClipboard', '{0} copied to clipboard', name); } +export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", (error instanceof Error ? error.message : error)); } +export function failedToManagePostgres(name: string, error: any): string { return localize('arc.failedToManagePostgres', "Failed to manage Postgres {0}. {1}", name, (error instanceof Error ? error.message : error)); } export const arcResources = localize('arc.arcResources', "Azure Arc Resources"); diff --git a/extensions/arc/src/models/controllerModel.ts b/extensions/arc/src/models/controllerModel.ts index 44143fcae3..a7822e27d1 100644 --- a/extensions/arc/src/models/controllerModel.ts +++ b/extensions/arc/src/models/controllerModel.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import { Authentication } from '../controller/auth'; import { EndpointsRouterApi, EndpointModel, RegistrationRouterApi, RegistrationResponse, TokenRouterApi } from '../controller/generated/v1/api'; @@ -10,9 +11,16 @@ export class ControllerModel { private _endpointsRouter: EndpointsRouterApi; private _tokenRouter: TokenRouterApi; private _registrationRouter: RegistrationRouterApi; - private _endpoints!: EndpointModel[]; - private _namespace!: string; - private _registrations!: RegistrationResponse[]; + private _endpoints?: EndpointModel[]; + private _namespace?: string; + private _registrations?: RegistrationResponse[]; + + private readonly _onEndpointsUpdated = new vscode.EventEmitter(); + private readonly _onRegistrationsUpdated = new vscode.EventEmitter(); + public onEndpointsUpdated = this._onEndpointsUpdated.event; + public onRegistrationsUpdated = this._onRegistrationsUpdated.event; + public endpointsLastUpdated?: Date; + public registrationsLastUpdated?: Date; constructor(controllerUrl: string, auth: Authentication) { this._endpointsRouter = new EndpointsRouterApi(controllerUrl); @@ -29,33 +37,36 @@ export class ControllerModel { await Promise.all([ this._endpointsRouter.apiV1BdcEndpointsGet().then(response => { this._endpoints = response.body; + this.endpointsLastUpdated = new Date(); + this._onEndpointsUpdated.fire(this._endpoints); }), this._tokenRouter.apiV1TokenPost().then(async response => { this._namespace = response.body.namespace!; + this._registrations = (await this._registrationRouter.apiV1RegistrationListResourcesNsGet(this._namespace)).body; + this.registrationsLastUpdated = new Date(); + this._onRegistrationsUpdated.fire(this._registrations); }) - ]).then(async _ => { - this._registrations = (await this._registrationRouter.apiV1RegistrationListResourcesNsGet(this._namespace)).body; - }); + ]); } - public endpoints(): EndpointModel[] { + public endpoints(): EndpointModel[] | undefined { return this._endpoints; } public endpoint(name: string): EndpointModel | undefined { - return this._endpoints.find(e => e.name === name); + return this._endpoints?.find(e => e.name === name); } - public namespace(): string { + public namespace(): string | undefined { return this._namespace; } - public registrations(): RegistrationResponse[] { + public registrations(): RegistrationResponse[] | undefined { return this._registrations; } public registration(type: string, namespace: string, name: string): RegistrationResponse | undefined { - return this._registrations.find(r => { + return this._registrations?.find(r => { // Resources deployed outside the controller's namespace are named in the format 'namespace_name' let instanceName = r.instanceName!; const parts: string[] = instanceName.split('_'); diff --git a/extensions/arc/src/models/postgresModel.ts b/extensions/arc/src/models/postgresModel.ts index 440531d9eb..a63407c5a8 100644 --- a/extensions/arc/src/models/postgresModel.ts +++ b/extensions/arc/src/models/postgresModel.ts @@ -3,14 +3,21 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import * as loc from '../localizedConstants'; import { DuskyObjectModelsDatabaseService, DatabaseRouterApi, DuskyObjectModelsDatabase, V1Status } from '../controller/generated/dusky/api'; import { Authentication } from '../controller/auth'; export class PostgresModel { private _databaseRouter: DatabaseRouterApi; - private _service!: DuskyObjectModelsDatabaseService; - private _password!: string; + private _service?: DuskyObjectModelsDatabaseService; + private _password?: string; + private readonly _onServiceUpdated = new vscode.EventEmitter(); + private readonly _onPasswordUpdated = new vscode.EventEmitter(); + public onServiceUpdated = this._onServiceUpdated.event; + public onPasswordUpdated = this._onPasswordUpdated.event; + public serviceLastUpdated?: Date; + public passwordLastUpdated?: Date; constructor(controllerUrl: string, auth: Authentication, private _namespace: string, private _name: string) { this._databaseRouter = new DatabaseRouterApi(controllerUrl); @@ -33,23 +40,27 @@ export class PostgresModel { } /** Returns the service's spec */ - public service(): DuskyObjectModelsDatabaseService { + public service(): DuskyObjectModelsDatabaseService | undefined { return this._service; } /** Returns the service's password */ - public password(): string { + public password(): string | undefined { return this._password; } - /** Refreshes the service's model */ + /** Refreshes the model */ public async refresh() { await Promise.all([ this._databaseRouter.getDuskyDatabaseService(this._namespace, this._name).then(response => { this._service = response.body; + this.serviceLastUpdated = new Date(); + this._onServiceUpdated.fire(this._service); }), this._databaseRouter.getDuskyPassword(this._namespace, this._name).then(async response => { this._password = response.body; + this.passwordLastUpdated = new Date(); + this._onPasswordUpdated.fire(this._password!); }) ]); } @@ -82,7 +93,7 @@ export class PostgresModel { /** Returns the number of nodes in the service */ public numNodes(): number { - let nodes = this._service.spec.scale?.shards ?? 1; + let nodes = this._service?.spec.scale?.shards ?? 1; if (nodes > 1) { nodes++; } // for multiple shards there is an additional node for the coordinator return nodes; } @@ -92,10 +103,10 @@ export class PostgresModel { * internal IP. If either field is not available it will be set to undefined. */ public endpoint(): { ip?: string, port?: number } { - const externalIp = this._service.status?.externalIP; - const internalIp = this._service.status?.internalIP; - const externalPort = this._service.status?.externalPort; - const internalPort = this._service.status?.internalPort; + const externalIp = this._service?.status?.externalIP; + const internalIp = this._service?.status?.internalIP; + const externalPort = this._service?.status?.externalPort; + const internalPort = this._service?.status?.internalPort; return externalIp ? { ip: externalIp, port: externalPort ?? undefined } : internalIp ? { ip: internalIp, port: internalPort ?? undefined } @@ -105,26 +116,22 @@ export class PostgresModel { /** Returns the service's configuration e.g. '3 nodes, 1.5 vCores, 1GiB RAM, 2GiB storage per node' */ public configuration(): string { const nodes = this.numNodes(); - const cpuLimit = this._service.spec.scheduling?.resources?.limits?.['cpu']; - const ramLimit = this._service.spec.scheduling?.resources?.limits?.['memory']; - const cpuRequest = this._service.spec.scheduling?.resources?.requests?.['cpu']; - const ramRequest = this._service.spec.scheduling?.resources?.requests?.['memory']; - const storage = this._service.spec.storage.volumeSize; + const cpuLimit = this._service?.spec.scheduling?.resources?.limits?.['cpu']; + const ramLimit = this._service?.spec.scheduling?.resources?.limits?.['memory']; + const cpuRequest = this._service?.spec.scheduling?.resources?.requests?.['cpu']; + const ramRequest = this._service?.spec.scheduling?.resources?.requests?.['memory']; + const storage = this._service?.spec.storage.volumeSize; // Prefer limits if they're provided, otherwise use requests if they're provided - let nodeConfiguration = `${nodes} ${nodes > 1 ? loc.nodes : loc.node}`; - if (cpuLimit) { - nodeConfiguration += `, ${this.formatCores(cpuLimit)} ${loc.vCores}`; - } else if (cpuRequest) { - nodeConfiguration += `, ${this.formatCores(cpuRequest)} ${loc.vCores}`; + let configuration = `${nodes} ${nodes > 1 ? loc.nodes : loc.node}`; + if (cpuLimit || cpuRequest) { + configuration += `, ${this.formatCores(cpuLimit ?? cpuRequest!)} ${loc.vCores}`; } - if (ramLimit) { - nodeConfiguration += `, ${this.formatMemory(ramLimit)} ${loc.ram}`; - } else if (ramRequest) { - nodeConfiguration += `, ${this.formatMemory(ramRequest)} ${loc.ram}`; + if (ramLimit || ramRequest) { + configuration += `, ${this.formatMemory(ramLimit ?? ramRequest!)} ${loc.ram}`; } - if (storage) { nodeConfiguration += `, ${storage} ${loc.storagePerNode}`; } - return nodeConfiguration; + if (storage) { configuration += `, ${storage} ${loc.storagePerNode}`; } + return configuration; } /** diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresBackupPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresBackupPage.ts index 966821035a..e0cccd65df 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresBackupPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresBackupPage.ts @@ -6,9 +6,9 @@ import * as azdata from 'azdata'; import * as loc from '../../../localizedConstants'; import { IconPathHelper } from '../../../constants'; -import { PostgresDashboardPage } from './postgresDashboardPage'; +import { DashboardPage } from '../../components/dashboardPage'; -export class PostgresBackupPage extends PostgresDashboardPage { +export class PostgresBackupPage extends DashboardPage { protected get title(): string { return loc.backup; } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresComputeStoragePage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresComputeStoragePage.ts index 539c5dc14a..f01148cb3d 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresComputeStoragePage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresComputeStoragePage.ts @@ -6,9 +6,9 @@ import * as azdata from 'azdata'; import * as loc from '../../../localizedConstants'; import { IconPathHelper } from '../../../constants'; -import { PostgresDashboardPage } from './postgresDashboardPage'; +import { DashboardPage } from '../../components/dashboardPage'; -export class PostgresComputeStoragePage extends PostgresDashboardPage { +export class PostgresComputeStoragePage extends DashboardPage { protected get title(): string { return loc.computeAndStorage; } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresConnectionStringsPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresConnectionStringsPage.ts index b0694548e5..678bc6c2aa 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresConnectionStringsPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresConnectionStringsPage.ts @@ -3,13 +3,23 @@ * 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 } from '../../../constants'; -import { PostgresDashboardPage } from './postgresDashboardPage'; -import { KeyValueContainer, KeyValue, InputKeyValue } from '../../components/keyValueContainer'; +import { KeyValueContainer, InputKeyValue } from '../../components/keyValueContainer'; +import { DashboardPage } from '../../components/dashboardPage'; +import { PostgresModel } from '../../../models/postgresModel'; + +export class PostgresConnectionStringsPage extends DashboardPage { + private keyValueContainer?: KeyValueContainer; + + constructor(protected modelView: azdata.ModelView, private _postgresModel: PostgresModel) { + super(modelView); + this._postgresModel.onServiceUpdated(() => this.eventuallyRunOnInitialized(() => this.refresh())); + this._postgresModel.onPasswordUpdated(() => this.eventuallyRunOnInitialized(() => this.refresh())); + } -export class PostgresConnectionStringsPage extends PostgresDashboardPage { protected get title(): string { return loc.connectionStrings; } @@ -46,10 +56,39 @@ export class PostgresConnectionStringsPage extends PostgresDashboardPage { this.modelView.modelBuilder.flexContainer().withItems([info, link]).withLayout({ flexWrap: 'wrap' }).component(), { CSSStyles: { display: 'inline-flex', 'margin-bottom': '25px' } }); - const endpoint: { ip?: string, port?: number } = this.databaseModel.endpoint(); - const password = this.databaseModel.password(); + this.keyValueContainer = new KeyValueContainer(this.modelView.modelBuilder, []); + content.addItem(this.keyValueContainer.container); + this.initialized = true; + return root; + } - const pairs: KeyValue[] = [ + protected get toolbarContainer(): azdata.ToolbarContainer { + const refreshButton = this.modelView.modelBuilder.button().withProperties({ + label: loc.refresh, + iconPath: IconPathHelper.refresh + }).component(); + + refreshButton.onDidClick(async () => { + refreshButton.enabled = false; + try { + 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 refresh() { + const endpoint: { ip?: string, port?: number } = this._postgresModel.endpoint(); + const password = this._postgresModel.password(); + + this.keyValueContainer?.refresh([ new InputKeyValue('ADO.NET', `Server=${endpoint.ip};Database=postgres;Port=${endpoint.port};User Id=postgres;Password=${password};Ssl Mode=Require;`), new InputKeyValue('C++ (libpq)', `host=${endpoint.ip} port=${endpoint.port} dbname=postgres user=postgres password=${password} sslmode=require`), new InputKeyValue('JDBC', `jdbc:postgresql://${endpoint.ip}:${endpoint.port}/postgres?user=postgres&password=${password}&sslmode=require`), @@ -59,14 +98,6 @@ export class PostgresConnectionStringsPage extends PostgresDashboardPage { new InputKeyValue('Python', `dbname='postgres' user='postgres' host='${endpoint.ip}' password='${password}' port='${endpoint.port}' sslmode='true'`), new InputKeyValue('Ruby', `host=${endpoint.ip}; dbname=postgres user=postgres password=${password} port=${endpoint.port} sslmode=require`), new InputKeyValue('Web App', `Database=postgres; Data Source=${endpoint.ip}; User Id=postgres; Password=${password}`) - ]; - - const keyValueContainer = new KeyValueContainer(this.modelView.modelBuilder, pairs); - content.addItem(keyValueContainer.container); - return root; - } - - protected get toolbarContainer(): azdata.ToolbarContainer { - return this.modelView.modelBuilder.toolbarContainer().component(); + ]); } } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts index 3b311233aa..4b69e7d837 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts @@ -16,19 +16,17 @@ import { PostgresNetworkingPage } from './postgresNetworkingPage'; import { Dashboard } from '../../components/dashboard'; export class PostgresDashboard extends Dashboard { - constructor(title: string, private _controllerModel: ControllerModel, private _databaseModel: PostgresModel) { + constructor(title: string, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { super(title); } protected async registerTabs(modelView: azdata.ModelView): Promise<(azdata.DashboardTab | azdata.DashboardTabGroup)[]> { - await Promise.all([this._controllerModel.refresh(), this._databaseModel.refresh()]); - - const overviewPage = new PostgresOverviewPage(modelView, this._controllerModel, this._databaseModel); - const computeStoragePage = new PostgresComputeStoragePage(modelView, this._controllerModel, this._databaseModel); - const connectionStringsPage = new PostgresConnectionStringsPage(modelView, this._controllerModel, this._databaseModel); - const backupPage = new PostgresBackupPage(modelView, this._controllerModel, this._databaseModel); - const propertiesPage = new PostgresPropertiesPage(modelView, this._controllerModel, this._databaseModel); - const networkingPage = new PostgresNetworkingPage(modelView, this._controllerModel, this._databaseModel); + const overviewPage = new PostgresOverviewPage(modelView, this._controllerModel, this._postgresModel); + const computeStoragePage = new PostgresComputeStoragePage(modelView); + const connectionStringsPage = new PostgresConnectionStringsPage(modelView, this._postgresModel); + const backupPage = new PostgresBackupPage(modelView); + const propertiesPage = new PostgresPropertiesPage(modelView, this._controllerModel, this._postgresModel); + const networkingPage = new PostgresNetworkingPage(modelView); return [ overviewPage.tab, diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresDashboardPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresDashboardPage.ts deleted file mode 100644 index f3664ee33a..0000000000 --- a/extensions/arc/src/ui/dashboards/postgres/postgresDashboardPage.ts +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { ControllerModel } from '../../../models/controllerModel'; -import { PostgresModel } from '../../../models/postgresModel'; -import { DashboardPage } from '../../components/dashboardPage'; - -export abstract class PostgresDashboardPage extends DashboardPage { - constructor(protected modelView: azdata.ModelView, protected controllerModel: ControllerModel, protected databaseModel: PostgresModel) { - super(modelView); - } -} diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresNetworkingPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresNetworkingPage.ts index 6062d150da..ae91757a42 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresNetworkingPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresNetworkingPage.ts @@ -6,9 +6,9 @@ import * as azdata from 'azdata'; import * as loc from '../../../localizedConstants'; import { IconPathHelper } from '../../../constants'; -import { PostgresDashboardPage } from './postgresDashboardPage'; +import { DashboardPage } from '../../components/dashboardPage'; -export class PostgresNetworkingPage extends PostgresDashboardPage { +export class PostgresNetworkingPage extends DashboardPage { protected get title(): string { return loc.networking; } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts index c6c3bfa712..5d03e12ac4 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts @@ -8,9 +8,32 @@ import * as azdata from 'azdata'; import * as loc from '../../../localizedConstants'; import { IconPathHelper, cssStyles } from '../../../constants'; import { DuskyObjectModelsDatabase, DuskyObjectModelsDatabaseServiceArcPayload } from '../../../controller/generated/dusky/api'; -import { PostgresDashboardPage } from './postgresDashboardPage'; +import { DashboardPage } from '../../components/dashboardPage'; +import { ControllerModel } from '../../../models/controllerModel'; +import { PostgresModel } from '../../../models/postgresModel'; + +export class PostgresOverviewPage extends DashboardPage { + private propertiesLoading?: azdata.LoadingComponent; + private kibanaLoading?: azdata.LoadingComponent; + private grafanaLoading?: azdata.LoadingComponent; + private nodesTableLoading?: azdata.LoadingComponent; + + private properties?: azdata.PropertiesContainerComponent; + private kibanaLink?: azdata.HyperlinkComponent; + private grafanaLink?: azdata.HyperlinkComponent; + private nodesTable?: azdata.DeclarativeTableComponent; + + constructor(protected modelView: azdata.ModelView, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { + super(modelView); + this._controllerModel.onEndpointsUpdated(() => this.eventuallyRunOnInitialized(() => this.refreshEndpoints())); + this._controllerModel.onRegistrationsUpdated(() => this.eventuallyRunOnInitialized(() => this.refreshProperties())); + this._postgresModel.onPasswordUpdated(() => this.eventuallyRunOnInitialized(() => this.refreshProperties())); + this._postgresModel.onServiceUpdated(() => this.eventuallyRunOnInitialized(() => { + this.refreshProperties(); + this.refreshNodes(); + })); + } -export class PostgresOverviewPage extends PostgresDashboardPage { protected get title(): string { return loc.overview; } @@ -28,34 +51,18 @@ export class PostgresOverviewPage extends PostgresDashboardPage { const content = this.modelView.modelBuilder.divContainer().component(); root.addItem(content, { CSSStyles: { 'margin': '10px 20px 0px 20px' } }); - const registration = this.controllerModel.registration('postgresInstances', this.databaseModel.namespace(), this.databaseModel.name()); - const endpoint: { ip?: string, port?: number } = this.databaseModel.endpoint(); - const essentials = this.modelView.modelBuilder.propertiesContainer().withProperties({ - propertyItems: [ - { displayName: loc.name, value: this.databaseModel.name() }, - { displayName: loc.serverGroupType, value: loc.postgresArcProductName }, - { displayName: loc.resourceGroup, value: registration?.resourceGroupName ?? 'None' }, - { displayName: loc.coordinatorEndpoint, value: `postgresql://postgres:${this.databaseModel.password()}@${endpoint.ip}:${endpoint.port}` }, - { displayName: loc.status, value: this.databaseModel.service().status?.state ?? '' }, - { displayName: loc.postgresAdminUsername, value: 'postgres' }, - { displayName: loc.dataController, value: this.controllerModel.namespace() }, - { displayName: loc.nodeConfiguration, value: this.databaseModel.configuration() }, - { displayName: loc.subscriptionId, value: registration?.subscriptionId ?? 'None' }, - { displayName: loc.postgresVersion, value: this.databaseModel.service().spec.engine.version?.toString() ?? '' } - ] - }).component(); - content.addItem(essentials, { CSSStyles: cssStyles.text }); + // Properties + this.properties = this.modelView.modelBuilder.propertiesContainer().component(); + this.propertiesLoading = this.modelView.modelBuilder.loadingComponent().withItem(this.properties).component(); + content.addItem(this.propertiesLoading, { CSSStyles: cssStyles.text }); // Service endpoints const titleCSS = { ...cssStyles.title, 'margin-block-start': '2em', 'margin-block-end': '0' }; content.addItem(this.modelView.modelBuilder.text().withProperties({ value: loc.serviceEndpoints, CSSStyles: titleCSS }).component()); - - const kibanaQuery = `kubernetes_namespace:"${this.databaseModel.namespace()}" and cluster_name:"${this.databaseModel.name()}"`; - const kibanaUrl = `${this.controllerModel.endpoint('logsui')?.endpoint}/app/kibana#/discover?_a=(query:(language:kuery,query:'${kibanaQuery}'))`; - const grafanaUrl = `${this.controllerModel.endpoint('metricsui')?.endpoint}/d/postgres-metrics?var-Namespace=${this.databaseModel.namespace()}&var-Name=${this.databaseModel.name()}`; - - const kibanaLink = this.modelView.modelBuilder.hyperlink().withProperties({ label: kibanaUrl, url: kibanaUrl, }).component(); - const grafanaLink = this.modelView.modelBuilder.hyperlink().withProperties({ label: grafanaUrl, url: grafanaUrl }).component(); + this.kibanaLink = this.modelView.modelBuilder.hyperlink().component(); + this.grafanaLink = this.modelView.modelBuilder.hyperlink().component(); + this.kibanaLoading = this.modelView.modelBuilder.loadingComponent().withItem(this.kibanaLink).component(); + this.grafanaLoading = this.modelView.modelBuilder.loadingComponent().withItem(this.grafanaLink).component(); const endpointsTable = this.modelView.modelBuilder.declarativeTable().withProperties({ width: '100%', @@ -92,14 +99,14 @@ export class PostgresOverviewPage extends PostgresDashboardPage { } ], data: [ - [loc.kibanaDashboard, kibanaLink, loc.kibanaDashboardDescription], - [loc.grafanaDashboard, grafanaLink, loc.grafanaDashboardDescription]] + [loc.kibanaDashboard, this.kibanaLoading, loc.kibanaDashboardDescription], + [loc.grafanaDashboard, this.grafanaLoading, loc.grafanaDashboardDescription]] }).component(); content.addItem(endpointsTable); // Server group nodes content.addItem(this.modelView.modelBuilder.text().withProperties({ value: loc.serverGroupNodes, CSSStyles: titleCSS }).component()); - const nodesTable = this.modelView.modelBuilder.declarativeTable().withProperties({ + this.nodesTable = this.modelView.modelBuilder.declarativeTable().withProperties({ width: '100%', columns: [ { @@ -130,15 +137,9 @@ export class PostgresOverviewPage extends PostgresDashboardPage { data: [] }).component(); - const nodes = this.databaseModel.numNodes(); - for (let i = 0; i < nodes; i++) { - nodesTable.data.push([ - `${this.databaseModel.name()}-${i}`, - i === 0 ? loc.coordinatorEndpoint : loc.worker, - i === 0 ? `${endpoint.ip}:${endpoint.port}` : `${this.databaseModel.name()}-${i}.${this.databaseModel.name()}-svc.${this.databaseModel.namespace()}.svc.cluster.local`]); - } - - content.addItem(nodesTable, { CSSStyles: { 'margin-bottom': '20px' } }); + this.nodesTableLoading = this.modelView.modelBuilder.loadingComponent().withItem(this.nodesTable).component(); + content.addItem(this.nodesTableLoading, { CSSStyles: { 'margin-bottom': '20px' } }); + this.initialized = true; return root; } @@ -150,14 +151,18 @@ export class PostgresOverviewPage extends PostgresDashboardPage { }).component(); newDatabaseButton.onDidClick(async () => { - const name = await vscode.window.showInputBox({ prompt: loc.databaseName }); - if (name === undefined) { return; } - const db: DuskyObjectModelsDatabase = { name: name }; // TODO support other options (sharded, owner) + newDatabaseButton.enabled = false; + let name; try { - await this.databaseModel.createDatabase(db); + name = await vscode.window.showInputBox({ prompt: loc.databaseName }); + if (name === undefined) { return; } + const db: DuskyObjectModelsDatabase = { name: name }; // TODO support other options (sharded, owner) + await this._postgresModel.createDatabase(db); vscode.window.showInformationMessage(loc.databaseCreated(db.name)); } catch (error) { - vscode.window.showErrorMessage(loc.databaseCreationFailed(db.name, error)); + vscode.window.showErrorMessage(loc.databaseCreationFailed(name ?? '', error)); + } finally { + newDatabaseButton.enabled = true; } }); @@ -168,16 +173,19 @@ export class PostgresOverviewPage extends PostgresDashboardPage { }).component(); resetPasswordButton.onDidClick(async () => { - const password = await vscode.window.showInputBox({ prompt: loc.newPassword, password: true }); - if (password === undefined) { return; } + resetPasswordButton.enabled = false; try { - await this.databaseModel.update(s => { + const password = await vscode.window.showInputBox({ prompt: loc.newPassword, password: true }); + if (password === undefined) { return; } + await this._postgresModel.update(s => { s.arc = s.arc ?? new DuskyObjectModelsDatabaseServiceArcPayload(); s.arc.servicePassword = password; }); - vscode.window.showInformationMessage(loc.passwordReset(this.databaseModel.fullName())); + vscode.window.showInformationMessage(loc.passwordReset(this._postgresModel.fullName())); } catch (error) { - vscode.window.showErrorMessage(loc.passwordResetFailed(this.databaseModel.fullName(), error)); + vscode.window.showErrorMessage(loc.passwordResetFailed(this._postgresModel.fullName(), error)); + } finally { + resetPasswordButton.enabled = true; } }); @@ -188,15 +196,44 @@ export class PostgresOverviewPage extends PostgresDashboardPage { }).component(); deleteButton.onDidClick(async () => { - const response = await vscode.window.showQuickPick([loc.yes, loc.no], { - placeHolder: loc.deleteServicePrompt(this.databaseModel.fullName()) - }); - if (response !== loc.yes) { return; } + deleteButton.enabled = false; try { - await this.databaseModel.delete(); - vscode.window.showInformationMessage(loc.serviceDeleted(this.databaseModel.fullName())); + const response = await vscode.window.showQuickPick([loc.yes, loc.no], { + placeHolder: loc.deleteServicePrompt(this._postgresModel.fullName()) + }); + if (response !== loc.yes) { return; } + await this._postgresModel.delete(); + vscode.window.showInformationMessage(loc.serviceDeleted(this._postgresModel.fullName())); } catch (error) { - vscode.window.showErrorMessage(loc.serviceDeletionFailed(this.databaseModel.fullName(), error)); + vscode.window.showErrorMessage(loc.serviceDeletionFailed(this._postgresModel.fullName(), error)); + } finally { + deleteButton.enabled = true; + } + }); + + // Refresh + const refreshButton = this.modelView.modelBuilder.button().withProperties({ + label: loc.refresh, + iconPath: IconPathHelper.refresh + }).component(); + + refreshButton.onDidClick(async () => { + refreshButton.enabled = false; + try { + this.propertiesLoading!.loading = true; + this.kibanaLoading!.loading = true; + this.grafanaLoading!.loading = true; + this.nodesTableLoading!.loading = true; + + await Promise.all([ + this._postgresModel.refresh(), + this._controllerModel.refresh() + ]); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + finally { + refreshButton.enabled = true; } }); @@ -207,27 +244,70 @@ export class PostgresOverviewPage extends PostgresDashboardPage { }).component(); openInAzurePortalButton.onDidClick(async () => { - const r = this.controllerModel.registration('postgresInstances', this.databaseModel.namespace(), this.databaseModel.name()); + const r = this._controllerModel.registration('postgresInstances', this._postgresModel.namespace(), this._postgresModel.name()); if (r === undefined) { - vscode.window.showErrorMessage(loc.couldNotFindAzureResource(this.databaseModel.fullName())); + vscode.window.showErrorMessage(loc.couldNotFindAzureResource(this._postgresModel.fullName())); } else { vscode.env.openExternal(vscode.Uri.parse( `https://portal.azure.com/#resource/subscriptions/${r.subscriptionId}/resourceGroups/${r.resourceGroupName}/providers/Microsoft.AzureData/postgresInstances/${r.instanceName}`)); } }); - // TODO implement click - const feedbackButton = this.modelView.modelBuilder.button().withProperties({ - label: loc.feedback, - iconPath: IconPathHelper.heart - }).component(); - return this.modelView.modelBuilder.toolbarContainer().withToolbarItems([ { component: newDatabaseButton }, { component: resetPasswordButton }, - { component: deleteButton, toolbarSeparatorAfter: true }, - { component: openInAzurePortalButton }, - { component: feedbackButton } + { component: deleteButton }, + { component: refreshButton, toolbarSeparatorAfter: true }, + { component: openInAzurePortalButton } ]).component(); } + + private refreshProperties() { + const registration = this._controllerModel.registration('postgresInstances', this._postgresModel.namespace(), this._postgresModel.name()); + const endpoint: { ip?: string, port?: number } = this._postgresModel.endpoint(); + + this.properties!.propertyItems = [ + { displayName: loc.name, value: this._postgresModel.name() }, + { displayName: loc.coordinatorEndpoint, value: `postgresql://postgres:${this._postgresModel.password()}@${endpoint.ip}:${endpoint.port}` }, + { displayName: loc.status, value: this._postgresModel.service()?.status?.state ?? '' }, + { displayName: loc.postgresAdminUsername, value: 'postgres' }, + { displayName: loc.dataController, value: this._controllerModel?.namespace() ?? '' }, + { displayName: loc.nodeConfiguration, value: this._postgresModel.configuration() }, + { displayName: loc.subscriptionId, value: registration?.subscriptionId ?? '' }, + { displayName: loc.postgresVersion, value: this._postgresModel.service()?.spec.engine.version?.toString() ?? '' } + ]; + + this.propertiesLoading!.loading = false; + } + + private refreshEndpoints() { + const kibanaQuery = `kubernetes_namespace:"${this._postgresModel.namespace()}" and cluster_name:"${this._postgresModel.name()}"`; + const kibanaUrl = `${this._controllerModel.endpoint('logsui')?.endpoint}/app/kibana#/discover?_a=(query:(language:kuery,query:'${kibanaQuery}'))`; + this.kibanaLink!.label = kibanaUrl; + this.kibanaLink!.url = kibanaUrl; + + const grafanaUrl = `${this._controllerModel.endpoint('metricsui')?.endpoint}/d/postgres-metrics?var-Namespace=${this._postgresModel.namespace()}&var-Name=${this._postgresModel.name()}`; + this.grafanaLink!.label = grafanaUrl; + this.grafanaLink!.url = grafanaUrl; + + this.kibanaLoading!.loading = false; + this.grafanaLoading!.loading = false; + } + + private refreshNodes() { + const nodes = this._postgresModel.numNodes(); + const endpoint: { ip?: string, port?: number } = this._postgresModel.endpoint(); + + const data: any[][] = []; + for (let i = 0; i < nodes; i++) { + data.push([ + `${this._postgresModel.name()}-${i}`, + i === 0 ? loc.coordinatorEndpoint : loc.worker, + i === 0 ? `${endpoint.ip}:${endpoint.port}` : + `${this._postgresModel.name()}-${i}.${this._postgresModel.name()}-svc.${this._postgresModel.namespace()}.svc.cluster.local`]); + } + + this.nodesTable!.data = data; + this.nodesTableLoading!.loading = false; + } } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresPropertiesPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresPropertiesPage.ts index 4a44636e7f..dd5a3125bb 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresPropertiesPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresPropertiesPage.ts @@ -7,11 +7,21 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as loc from '../../../localizedConstants'; import { IconPathHelper, cssStyles } from '../../../constants'; -import { PostgresDashboardPage } from './postgresDashboardPage'; -import { KeyValueContainer, KeyValue, InputKeyValue, LinkKeyValue, TextKeyValue } from '../../components/keyValueContainer'; +import { KeyValueContainer, InputKeyValue, LinkKeyValue, TextKeyValue } from '../../components/keyValueContainer'; +import { DashboardPage } from '../../components/dashboardPage'; +import { ControllerModel } from '../../../models/controllerModel'; +import { PostgresModel } from '../../../models/postgresModel'; +export class PostgresPropertiesPage extends DashboardPage { + private keyValueContainer?: KeyValueContainer; + + constructor(protected modelView: azdata.ModelView, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { + super(modelView); + this._postgresModel.onServiceUpdated(() => this.eventuallyRunOnInitialized(() => this.refresh())); + this._postgresModel.onPasswordUpdated(() => this.eventuallyRunOnInitialized(() => this.refresh())); + this._controllerModel.onRegistrationsUpdated(() => this.eventuallyRunOnInitialized(() => this.refresh())); + } -export class PostgresPropertiesPage extends PostgresDashboardPage { protected get title(): string { return loc.properties; } @@ -34,27 +44,52 @@ export class PostgresPropertiesPage extends PostgresDashboardPage { CSSStyles: { ...cssStyles.title, 'margin-bottom': '25px' } }).component()); - const endpoint: { ip?: string, port?: number } = this.databaseModel.endpoint(); - const connectionString = `postgresql://postgres:${this.databaseModel.password()}@${endpoint.ip}:${endpoint.port}`; - const registration = this.controllerModel.registration('postgresInstances', this.databaseModel.namespace(), this.databaseModel.name()); - - const pairs: KeyValue[] = [ - new InputKeyValue(loc.coordinatorEndpoint, connectionString), - new InputKeyValue(loc.postgresAdminUsername, 'postgres'), - new TextKeyValue(loc.status, this.databaseModel.service().status?.state ?? 'Unknown'), - new LinkKeyValue(loc.dataController, this.controllerModel.namespace(), _ => vscode.window.showInformationMessage('goto data controller')), - new LinkKeyValue(loc.nodeConfiguration, this.databaseModel.configuration(), _ => vscode.window.showInformationMessage('goto configuration')), - new TextKeyValue(loc.postgresVersion, this.databaseModel.service().spec.engine.version?.toString() ?? ''), - new TextKeyValue(loc.resourceGroup, registration?.resourceGroupName ?? ''), - new TextKeyValue(loc.subscriptionId, registration?.subscriptionId ?? '') - ]; - - const keyValueContainer = new KeyValueContainer(this.modelView.modelBuilder, pairs); - content.addItem(keyValueContainer.container); + this.keyValueContainer = new KeyValueContainer(this.modelView.modelBuilder, []); + content.addItem(this.keyValueContainer.container); + this.initialized = true; return root; } protected get toolbarContainer(): azdata.ToolbarContainer { - return this.modelView.modelBuilder.toolbarContainer().component(); + const refreshButton = this.modelView.modelBuilder.button().withProperties({ + label: loc.refresh, + iconPath: IconPathHelper.refresh + }).component(); + + refreshButton.onDidClick(async () => { + refreshButton.enabled = false; + try { + await Promise.all([ + this._postgresModel.refresh(), + this._controllerModel.refresh() + ]); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + finally { + refreshButton.enabled = true; + } + }); + + return this.modelView.modelBuilder.toolbarContainer().withToolbarItems([ + { component: refreshButton } + ]).component(); + } + + private refresh() { + const endpoint: { ip?: string, port?: number } = this._postgresModel.endpoint(); + const connectionString = `postgresql://postgres:${this._postgresModel.password()}@${endpoint.ip}:${endpoint.port}`; + const registration = this._controllerModel.registration('postgresInstances', this._postgresModel.namespace(), this._postgresModel.name()); + + this.keyValueContainer?.refresh([ + new InputKeyValue(loc.coordinatorEndpoint, connectionString), + new InputKeyValue(loc.postgresAdminUsername, 'postgres'), + new TextKeyValue(loc.status, this._postgresModel.service()?.status?.state ?? 'Unknown'), + new LinkKeyValue(loc.dataController, this._controllerModel.namespace() ?? '', _ => vscode.window.showInformationMessage('TODO: Go to data controller')), + new LinkKeyValue(loc.nodeConfiguration, this._postgresModel.configuration(), _ => vscode.window.showInformationMessage('TODO: Go to configuration')), + new TextKeyValue(loc.postgresVersion, this._postgresModel.service()?.spec.engine.version?.toString() ?? ''), + new TextKeyValue(loc.resourceGroup, registration?.resourceGroupName ?? ''), + new TextKeyValue(loc.subscriptionId, registration?.subscriptionId ?? '') + ]); } }