/*--------------------------------------------------------------------------------------------- * 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 { IconPathHelper } from '../constants/iconPathHelper'; import { getCurrentMigrations, getSelectedServiceStatus } from '../models/migrationLocalStorage'; import * as loc from '../constants/strings'; import { filterMigrations, getMigrationDuration, getMigrationStatusImage, getMigrationStatusWithErrors, getMigrationTime, MenuCommands } from '../api/utils'; import { getMigrationTargetType, getMigrationMode, canCancelMigration, canCutoverMigration } from '../constants/helper'; import { DatabaseMigration, getResourceName } from '../api/azure'; import { logError, TelemetryViews } from '../telemtery'; import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog'; import { AdsMigrationStatus, EmptySettingValue, ServiceContextChangeEvent, TabBase } from './tabBase'; import { DashboardStatusBar } from './DashboardStatusBar'; export const MigrationsListTabId = 'MigrationsListTab'; const TableColumns = { sourceDatabase: 'sourceDatabase', sourceServer: 'sourceServer', status: 'status', mode: 'mode', targetType: 'targetType', targetDatabse: 'targetDatabase', targetServer: 'TargetServer', duration: 'duration', startTime: 'startTime', finishTime: 'finishTime', }; export class MigrationsListTab extends TabBase { private _searchBox!: azdata.InputBoxComponent; private _refresh!: azdata.ButtonComponent; private _serviceContextButton!: azdata.ButtonComponent; private _statusDropdown!: azdata.DropDownComponent; private _columnSortDropdown!: azdata.DropDownComponent; private _columnSortCheckbox!: azdata.CheckBoxComponent; private _statusTable!: azdata.TableComponent; private _refreshLoader!: azdata.LoadingComponent; private _filteredMigrations: DatabaseMigration[] = []; private _openMigrationDetails!: (migration: DatabaseMigration) => Promise; private _migrations: DatabaseMigration[] = []; constructor() { super(); this.id = MigrationsListTabId; } public async create( context: vscode.ExtensionContext, view: azdata.ModelView, openMigrationDetails: (migration: DatabaseMigration) => Promise, serviceContextChangedEvent: vscode.EventEmitter, statusBar: DashboardStatusBar, ): Promise { this.view = view; this.context = context; this._openMigrationDetails = openMigrationDetails; this.serviceContextChangedEvent = serviceContextChangedEvent; this.statusBar = statusBar; await this.initialize(); return this; } public async setMigrationFilter(filter: AdsMigrationStatus): Promise { if (this._statusDropdown.values && this._statusDropdown.values.length > 0) { const statusFilter = (this._statusDropdown.values) .find(value => value.name === filter.toString()); await this._statusDropdown.updateProperties({ 'value': statusFilter }); } } public async refresh(): Promise { if (this.isRefreshing || this._refreshLoader === undefined) { return; } try { this.isRefreshing = true; this._refreshLoader.loading = true; await this.statusBar.clearError(); await this._statusTable.updateProperty('data', []); this._migrations = await getCurrentMigrations(); await this._populateMigrationTable(); } catch (e) { await this.statusBar.showError( loc.DASHBOARD_REFRESH_MIGRATIONS_TITLE, loc.DASHBOARD_REFRESH_MIGRATIONS_LABEL, e.message); logError(TelemetryViews.MigrationsTab, 'refreshMigrations', e); } finally { this._refreshLoader.loading = false; this.isRefreshing = false; } } protected async initialize(): Promise { this._createStatusTable(); this.content = this.view.modelBuilder.flexContainer() .withItems( [ this._createToolbar(), await this._createSearchAndSortContainer(), this._statusTable, ], { CSSStyles: { 'width': '100%' } } ).withLayout({ width: '100%', flexFlow: 'column' }) .withProps({ CSSStyles: { 'padding': '0px' } }) .component(); } private _createToolbar(): azdata.ToolbarContainer { const toolbar = this.view.modelBuilder.toolbarContainer(); this._refresh = this.view.modelBuilder.button() .withProps({ iconPath: IconPathHelper.refresh, iconHeight: 24, iconWidth: 24, height: 24, label: loc.REFRESH_BUTTON_LABEL, }).component(); this.disposables.push( this._refresh.onDidClick( async (e) => await this.refresh())); this._refreshLoader = this.view.modelBuilder.loadingComponent() .withItem(this._refresh) .withProps({ loading: false, CSSStyles: { 'height': '8px', 'margin-top': '6px' } }).component(); toolbar.addToolbarItems([ // { component: this.createNewLoginMigrationButton(), toolbarSeparatorAfter: true }, { component: this.createNewMigrationButton(), toolbarSeparatorAfter: true }, { component: this.createNewSupportRequestButton() }, { component: this.createFeedbackButton(), toolbarSeparatorAfter: true }, { component: this._refreshLoader }, ]); return toolbar.component(); } private async _createSearchAndSortContainer(): Promise { const serviceContextLabel = await getSelectedServiceStatus(); this._serviceContextButton = this.view.modelBuilder.button() .withProps({ iconPath: IconPathHelper.sqlMigrationService, iconHeight: 22, iconWidth: 22, label: serviceContextLabel, title: serviceContextLabel, description: loc.MIGRATION_SERVICE_DESCRIPTION, buttonType: azdata.ButtonType.Informational, width: 230, }).component(); this.disposables.push( this._serviceContextButton.onDidClick( async () => { const dialog = new SelectMigrationServiceDialog(this.serviceContextChangedEvent); await dialog.initialize(); })); const connectionProfile = await azdata.connection.getCurrentConnection(); this.disposables.push( this.serviceContextChangedEvent.event( async (e) => { if (e.connectionId === connectionProfile.connectionId) { await this.updateServiceContext(this._serviceContextButton); await this.refresh(); } } )); await this.updateServiceContext(this._serviceContextButton); this._searchBox = this.view.modelBuilder.inputBox() .withProps({ stopEnterPropagation: true, placeHolder: loc.SEARCH_FOR_MIGRATIONS, width: '200px', }).component(); this.disposables.push( this._searchBox.onTextChanged( async (value) => await this._populateMigrationTable())); const searchLabel = this.view.modelBuilder.text() .withProps({ value: loc.STATUS_LABEL, CSSStyles: { 'font-size': '13px', 'font-weight': '600', 'margin': '3px 0 0 0', }, }).component(); this._statusDropdown = this.view.modelBuilder.dropDown() .withProps({ ariaLabel: loc.MIGRATION_STATUS_FILTER, values: this._statusDropdownValues, width: '150px', fireOnTextChange: true, value: this._statusDropdownValues[0], }).component(); this.disposables.push( this._statusDropdown.onValueChanged( async (value) => await this._populateMigrationTable())); const searchContainer = this.view.modelBuilder.flexContainer() .withLayout({ alignContent: 'center', alignItems: 'center', }).withProps({ CSSStyles: { 'margin-left': '10px' } }) .component(); searchContainer.addItem(searchLabel, { flex: '0' }); searchContainer.addItem(this._statusDropdown, { flex: '0', CSSStyles: { 'margin-left': '5px' } }); const sortLabel = this.view.modelBuilder.text() .withProps({ value: loc.SORT_LABEL, CSSStyles: { 'font-size': '13px', 'font-weight': '600', 'margin': '3px 0 0 0', }, }).component(); this._columnSortDropdown = this.view.modelBuilder.dropDown() .withProps({ editable: false, width: 120, CSSStyles: { 'margin-left': '5px' }, value: { name: TableColumns.startTime, displayName: loc.START_TIME }, values: [ { name: TableColumns.sourceDatabase, displayName: loc.SRC_DATABASE }, { name: TableColumns.sourceServer, displayName: loc.SRC_SERVER }, { name: TableColumns.status, displayName: loc.STATUS_COLUMN }, { name: TableColumns.mode, displayName: loc.MIGRATION_MODE }, { name: TableColumns.targetType, displayName: loc.AZURE_SQL_TARGET }, { name: TableColumns.targetDatabse, displayName: loc.TARGET_DATABASE_COLUMN }, { name: TableColumns.targetServer, displayName: loc.TARGET_SERVER_COLUMN }, { name: TableColumns.duration, displayName: loc.DURATION }, { name: TableColumns.startTime, displayName: loc.START_TIME }, { name: TableColumns.finishTime, displayName: loc.FINISH_TIME }, ], }) .component(); this.disposables.push( this._columnSortDropdown.onValueChanged( async (e) => await this._populateMigrationTable())); this._columnSortCheckbox = this.view.modelBuilder.checkBox() .withProps({ label: loc.ASCENDING_LABEL, checked: false, CSSStyles: { 'margin-left': '15px' }, }) .component(); this.disposables.push( this._columnSortCheckbox.onChanged( async (e) => await this._populateMigrationTable())); const columnSortContainer = this.view.modelBuilder.flexContainer() .withItems([sortLabel, this._columnSortDropdown]) .withProps({ CSSStyles: { 'justify-content': 'left', 'align-items': 'center', 'padding': '0px', 'display': 'flex', 'flex-direction': 'row', }, }).component(); columnSortContainer.addItem(this._columnSortCheckbox, { flex: '0 0 auto' }); const flexContainer = this.view.modelBuilder.flexContainer() .withProps({ width: '100%', CSSStyles: { 'justify-content': 'left', 'align-items': 'center', 'padding': '0px', 'display': 'flex', 'flex-direction': 'row', 'flex-flow': 'wrap', }, }).component(); flexContainer.addItem(this._serviceContextButton, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); flexContainer.addItem(this._searchBox, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); flexContainer.addItem(searchContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); flexContainer.addItem(columnSortContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); const container = this.view.modelBuilder.flexContainer() .withProps({ width: '100%' }) .component(); container.addItem(flexContainer); return container; } private _sortMigrations(migrations: DatabaseMigration[], columnName: string, ascending: boolean): void { const sortDir = ascending ? -1 : 1; switch (columnName) { case TableColumns.sourceDatabase: migrations.sort( (m1, m2) => this.stringCompare( m1.properties.sourceDatabaseName, m2.properties.sourceDatabaseName, sortDir)); return; case TableColumns.sourceServer: migrations.sort( (m1, m2) => this.stringCompare( m1.properties.sourceServerName, m2.properties.sourceServerName, sortDir)); return; case TableColumns.status: migrations.sort( (m1, m2) => this.stringCompare( getMigrationStatusWithErrors(m1), getMigrationStatusWithErrors(m2), sortDir)); return; case TableColumns.mode: migrations.sort( (m1, m2) => this.stringCompare( getMigrationMode(m1), getMigrationMode(m2), sortDir)); return; case TableColumns.targetType: migrations.sort( (m1, m2) => this.stringCompare( getMigrationTargetType(m1), getMigrationTargetType(m2), sortDir)); return; case TableColumns.targetDatabse: migrations.sort( (m1, m2) => this.stringCompare( getResourceName(m1.id), getResourceName(m2.id), sortDir)); return; case TableColumns.targetServer: migrations.sort( (m1, m2) => this.stringCompare( getResourceName(m1.properties.scope), getResourceName(m2.properties.scope), sortDir)); return; case TableColumns.duration: migrations.sort((m1, m2) => { if (!m1.properties.startedOn) { return sortDir; } else if (!m2.properties.startedOn) { return -sortDir; } const m1_startedOn = new Date(m1.properties.startedOn); const m2_startedOn = new Date(m2.properties.startedOn); const m1_endedOn = new Date(m1.properties.endedOn ?? Date.now()); const m2_endedOn = new Date(m2.properties.endedOn ?? Date.now()); const m1_duration = m1_endedOn.getTime() - m1_startedOn.getTime(); const m2_duration = m2_endedOn.getTime() - m2_startedOn.getTime(); return m1_duration > m2_duration ? -sortDir : sortDir; }); return; case TableColumns.startTime: migrations.sort( (m1, m2) => this.dateCompare( m1.properties.startedOn, m2.properties.startedOn, sortDir)); return; case TableColumns.finishTime: migrations.sort( (m1, m2) => this.dateCompare( m1.properties.endedOn, m2.properties.endedOn, sortDir)); return; } } private async _populateMigrationTable(): Promise { try { this._filteredMigrations = filterMigrations( this._migrations, (this._statusDropdown.value).name, this._searchBox.value!); this._sortMigrations( this._filteredMigrations, (this._columnSortDropdown.value).name, this._columnSortCheckbox.checked === true); const connectionProfile = await azdata.connection.getCurrentConnection(); const data: any[] = this._filteredMigrations.map((migration, index) => { return [ { icon: IconPathHelper.sqlDatabaseLogo, title: migration.properties.sourceDatabaseName ?? EmptySettingValue, }, // sourceDatabase migration.properties.sourceServerName ?? EmptySettingValue, // sourceServer { icon: getMigrationStatusImage(migration), title: getMigrationStatusWithErrors(migration), }, // statue getMigrationMode(migration), // mode getMigrationTargetType(migration), // targetType getResourceName(migration.id), // targetDatabase getResourceName(migration.properties.scope), // targetServer getMigrationDuration( migration.properties.startedOn, migration.properties.endedOn), // duration getMigrationTime(migration.properties.startedOn), // startTime getMigrationTime(migration.properties.endedOn), // finishTime { title: '', context: { connectionId: connectionProfile.connectionId, migrationId: migration.id, migrationOperationId: migration.properties.migrationOperationId, }, commands: this._getMenuCommands(migration), // context menu }, ]; }); await this._statusTable.updateProperty('data', data); } catch (e) { await this.statusBar.showError( loc.LOAD_MIGRATION_LIST_ERROR, loc.LOAD_MIGRATION_LIST_ERROR, e.message); logError(TelemetryViews.MigrationStatusDialog, 'Error populating migrations list page', e); } } private _createStatusTable(): azdata.TableComponent { const headerCssStyles = undefined; const rowCssStyles = undefined; this._statusTable = this.view.modelBuilder.table() .withProps({ ariaLabel: loc.MIGRATION_STATUS, CSSStyles: { 'margin-left': '10px' }, data: [], forceFitColumns: azdata.ColumnSizingMode.AutoFit, height: '500px', columns: [ { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.SRC_DATABASE, value: 'sourceDatabase', width: 170, type: azdata.ColumnType.hyperlink, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.SRC_SERVER, value: 'sourceServer', width: 170, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.STATUS_COLUMN, value: 'status', width: 160, type: azdata.ColumnType.hyperlink, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.MIGRATION_MODE, value: 'mode', width: 55, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.AZURE_SQL_TARGET, value: 'targetType', width: 120, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.TARGET_DATABASE_COLUMN, value: 'targetDatabase', width: 125, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.TARGET_SERVER_COLUMN, value: 'targetServer', width: 125, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.DURATION, value: 'duration', width: 55, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.START_TIME, value: 'startTime', width: 115, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: loc.FINISH_TIME, value: 'finishTime', width: 115, type: azdata.ColumnType.text, }, { cssClass: rowCssStyles, headerCssClass: headerCssStyles, name: '', value: 'contextMenu', width: 25, type: azdata.ColumnType.contextMenu, } ] }).component(); this.disposables.push( this._statusTable.onCellAction!(async (rowState: azdata.ICellActionEventArgs) => { const buttonState = rowState; const migration = this._filteredMigrations[rowState.row]; switch (buttonState?.column) { // "Migration status" column case 2: const statusMessage = loc.DATABASE_MIGRATION_STATUS_LABEL(getMigrationStatusWithErrors(migration)); const errors = this.getMigrationErrors(migration!); this.showDialogMessage( loc.DATABASE_MIGRATION_STATUS_TITLE, statusMessage, errors); break; // "Source database" column case 0: await this._openMigrationDetails(migration); break; } })); return this._statusTable; } private _getMenuCommands(migration: DatabaseMigration): string[] { const menuCommands: string[] = []; if (canCutoverMigration(migration)) { menuCommands.push(MenuCommands.Cutover); } menuCommands.push(...[ MenuCommands.ViewDatabase, MenuCommands.ViewTarget, MenuCommands.ViewService, MenuCommands.CopyMigration]); if (canCancelMigration(migration)) { menuCommands.push(MenuCommands.CancelMigration); } return menuCommands; } private _statusDropdownValues: azdata.CategoryValue[] = [ { displayName: loc.STATUS_ALL, name: AdsMigrationStatus.ALL }, { displayName: loc.STATUS_ONGOING, name: AdsMigrationStatus.ONGOING }, { displayName: loc.STATUS_COMPLETING, name: AdsMigrationStatus.COMPLETING }, { displayName: loc.STATUS_SUCCEEDED, name: AdsMigrationStatus.SUCCEEDED }, { displayName: loc.STATUS_FAILED, name: AdsMigrationStatus.FAILED } ]; }