From 754a1d887136c6902b307cea7fc641cda2021b5c Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 15 Aug 2024 12:36:20 -0500 Subject: [PATCH] Server tree view (#7) --- package.json | 35 +++++++++++ src/common/constants.ts | 7 +++ src/common/types.d.ts | 13 +++++ src/dh/dhc.ts | 14 +++++ src/dh/dhe.ts | 17 ++++++ src/extension.ts | 4 +- src/services/Config.ts | 3 +- src/services/ExtensionController.ts | 64 ++++++++++++++++++-- src/services/TreeProvider.ts | 90 +++++++++++++++++++++++++++++ src/services/WorkerManager.ts | 65 +++++++++++++++++++++ src/services/index.ts | 2 + src/services/types.d.ts | 27 +++++++++ src/util/downloadUtils.ts | 45 ++++++++++++++- src/util/errorUtils.ts | 22 ++++++- src/util/workerUtils.ts | 11 ++++ 15 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/dh/dhe.ts create mode 100644 src/services/TreeProvider.ts create mode 100644 src/services/WorkerManager.ts create mode 100644 src/services/types.d.ts create mode 100644 src/util/workerUtils.ts diff --git a/package.json b/package.json index 11cf968c..58cff270 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,11 @@ { "command": "vscode-deephaven.downloadLogs", "title": "Deephaven: Download Logs" + }, + { + "command": "vscode-deephaven.createWorker", + "title": "Deephaven: Create Worker", + "icon": "$(add)" } ], "menus": { @@ -151,6 +156,36 @@ "group": "navigation", "when": "editorLangId == python || editorLangId == groovy" } + ], + "view/item/context": [ + { + "command": "vscode-deephaven.createWorker", + "when": "view == vscode-deephaven.serverTree", + "group": "inline" + } + ] + }, + "viewsContainers": { + "activitybar": [ + { + "id": "vscode-deephaven", + "title": "Deephaven", + "icon": "images/dh-community-on-dark-128.svg" + } + ] + }, + "views": { + "vscode-deephaven": [ + { + "id": "vscode-deephaven.serverTree", + "name": "Servers", + "type": "tree" + }, + { + "id": "vscode-deephaven.workerTree", + "name": "Workers", + "type": "tree" + } ] } }, diff --git a/src/common/constants.ts b/src/common/constants.ts index 1cccd091..a11228ca 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -15,6 +15,8 @@ export const RUN_CODE_COMMAND = `${EXTENSION_ID}.runCode`; export const RUN_SELECTION_COMMAND = `${EXTENSION_ID}.runSelection`; export const SELECT_CONNECTION_COMMAND = `${EXTENSION_ID}.selectConnection`; +export const SERVER_STATUS_CHECK_INTERVAL = 3000; + export const STATUS_BAR_DISCONNECTED_TEXT = 'Deephaven: Disconnected'; export const STATUS_BAR_DISCONNECT_TEXT = 'Deephaven: Disconnect'; export const STATUS_BAR_CONNECTING_TEXT = 'Deephaven: Connecting...'; @@ -26,3 +28,8 @@ export const SERVER_LANGUAGE_SET = new Set(['python', 'groovy']) as ReadonlySet< >; export const TMP_DIR_ROOT = path.join(__dirname, '..', 'tmp'); + +export const VIEW_ID = { + serverTree: `${EXTENSION_ID}.serverTree`, + workerTree: `${EXTENSION_ID}.workerTree`, +} as const; diff --git a/src/common/types.d.ts b/src/common/types.d.ts index 47379ef5..007e7632 100644 --- a/src/common/types.d.ts +++ b/src/common/types.d.ts @@ -28,3 +28,16 @@ export interface EnterpriseConnectionConfig { export interface Disposable { dispose(): Promise; } + +export type ServerType = 'DHC' | 'DHE'; + +export interface ServerState { + type: ServerType; + url: string; + isRunning?: boolean; +} + +export interface WorkerState { + url: string; + consoleType: ConsoleType; +} diff --git a/src/dh/dhc.ts b/src/dh/dhc.ts index 6d2eaf0e..25654883 100644 --- a/src/dh/dhc.ts +++ b/src/dh/dhc.ts @@ -4,6 +4,7 @@ import type { dh as DhType } from '@deephaven/jsapi-types'; import { downloadFromURL, getTempDir, + hasStatusCode, NoConsoleTypesError, polyfillDh, urlToDirectoryName, @@ -16,6 +17,19 @@ export const AUTH_HANDLER_TYPE_ANONYMOUS = export const AUTH_HANDLER_TYPE_PSK = 'io.deephaven.authentication.psk.PskAuthenticationHandler'; +/** + * Check if a given server is running by checking if the `dh-core.js` file is + * accessible. + * @param serverUrl + */ +export async function isDhcServerRunning(serverUrl: string): Promise { + try { + return await hasStatusCode(new URL('jsapi/dh-core.js', serverUrl), 200); + } catch { + return false; + } +} + /** * Get embed widget url for a widget. * @param serverUrl diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts new file mode 100644 index 00000000..636acd9f --- /dev/null +++ b/src/dh/dhe.ts @@ -0,0 +1,17 @@ +import { hasStatusCode } from '../util'; + +/** + * Check if a given server is running by checking if the `irisapi/irisapi.nocache.js` + * file is accessible. + * @param serverUrl + */ +export async function isDheServerRunning(serverUrl: string): Promise { + try { + return await hasStatusCode( + new URL('irisapi/irisapi.nocache.js', serverUrl), + 200 + ); + } catch { + return false; + } +} diff --git a/src/extension.ts b/src/extension.ts index 4f8aece0..84bc0059 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,8 @@ import * as vscode from 'vscode'; -import { ExtensionController } from './services'; +import { ConfigService, ExtensionController } from './services'; export function activate(context: vscode.ExtensionContext): void { - const controller = new ExtensionController(context); + const controller = new ExtensionController(context, ConfigService); context.subscriptions.push(controller); } diff --git a/src/services/Config.ts b/src/services/Config.ts index 4876b931..c1e7e838 100644 --- a/src/services/Config.ts +++ b/src/services/Config.ts @@ -9,6 +9,7 @@ import { SERVER_LANGUAGE_SET, } from '../common'; import { InvalidConsoleTypeError, Logger } from '../util'; +import type { IConfigService } from './types'; const logger = new Logger('Config'); @@ -74,7 +75,7 @@ function getEnterpriseServers(): EnterpriseConnectionConfig[] { } // eslint-disable-next-line @typescript-eslint/naming-convention -export const Config = { +export const ConfigService: IConfigService = { getCoreServers, getEnterpriseServers, }; diff --git a/src/services/ExtensionController.ts b/src/services/ExtensionController.ts index 5f2c2fbd..05002003 100644 --- a/src/services/ExtensionController.ts +++ b/src/services/ExtensionController.ts @@ -5,6 +5,8 @@ import { RUN_CODE_COMMAND, RUN_SELECTION_COMMAND, SELECT_CONNECTION_COMMAND, + SERVER_STATUS_CHECK_INTERVAL, + VIEW_ID, } from '../common'; import { assertDefined, @@ -25,12 +27,17 @@ import { RunCommandCodeLensProvider } from './RunCommandCodeLensProvider'; import { DhServiceRegistry } from './DhServiceRegistry'; import { DhService } from './DhService'; import { DhcService } from './DhcService'; -import { Config } from './Config'; +import { ServerTreeProvider, WorkerTreeProvider } from './TreeProvider'; +import { WorkerManager } from './WorkerManager'; +import { IConfigService, IWorkerManager } from './types'; const logger = new Logger('ExtensionController'); export class ExtensionController implements Disposable { - constructor(private context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext, configService: IConfigService) { + this.context = context; + this.config = configService; + this.initializeDiagnostics(); this.initializeConfig(); this.initializeCodeLenses(); @@ -40,6 +47,7 @@ export class ExtensionController implements Disposable { this.initializeConnectionOptions(); this.initializeConnectionStatusBarItem(); this.initializeCommands(); + this.initializeWebViews(); logger.info( 'Congratulations, your extension "vscode-deephaven" is now active!' @@ -51,9 +59,12 @@ export class ExtensionController implements Disposable { return this.clearConnection(); } + context: vscode.ExtensionContext; + config: IConfigService; selectedConnectionUrl: string | null = null; selectedDhService: DhService | null = null; dhcServiceRegistry: DhServiceRegistry | null = null; + workerManager: IWorkerManager | null = null; connectionOptions: ConnectionOption[] = []; connectStatusBarItem: vscode.StatusBarItem | null = null; @@ -92,13 +103,15 @@ export class ExtensionController implements Disposable { * Initialize connection options. */ initializeConnectionOptions = (): void => { - this.connectionOptions = createConnectionOptions(Config.getCoreServers()); + this.connectionOptions = createConnectionOptions( + this.config.getCoreServers() + ); // Update connection options when configuration changes vscode.workspace.onDidChangeConfiguration( () => { this.connectionOptions = createConnectionOptions( - Config.getCoreServers() + this.config.getCoreServers() ); }, null, @@ -211,6 +224,49 @@ export class ExtensionController implements Disposable { this.registerCommand(SELECT_CONNECTION_COMMAND, this.onSelectConnection); }; + /** + * Register web views for the extension. + */ + initializeWebViews = (): void => { + this.workerManager = new WorkerManager(this.config); + + let timeout: NodeJS.Timeout; + const checkStatuses = async (): Promise => { + const start = performance.now(); + + await this.workerManager?.updateStatus(); + + // Ensure checks don't run more often than the interval + const elapsed = performance.now() - start; + const remaining = SERVER_STATUS_CHECK_INTERVAL - elapsed; + const wait = Math.max(0, remaining); + + timeout = setTimeout(checkStatuses, wait); + }; + checkStatuses(); + + function disposeCheckStatuses(): void { + clearTimeout(timeout); + } + + const serversView = vscode.window.registerTreeDataProvider( + VIEW_ID.serverTree, + new ServerTreeProvider(this.workerManager) + ); + + const workersView = vscode.window.registerTreeDataProvider( + VIEW_ID.workerTree, + new WorkerTreeProvider(this.workerManager) + ); + + this.context.subscriptions.push( + this.workerManager, + { dispose: disposeCheckStatuses }, + serversView, + workersView + ); + }; + /* * Clear connection data */ diff --git a/src/services/TreeProvider.ts b/src/services/TreeProvider.ts new file mode 100644 index 00000000..4ccf99d1 --- /dev/null +++ b/src/services/TreeProvider.ts @@ -0,0 +1,90 @@ +import * as vscode from 'vscode'; +import { ServerState, WorkerState } from '../common'; +import { IWorkerManager } from './types'; + +/** + * Base class for tree view data providers. + */ +export abstract class TreeProvider implements vscode.TreeDataProvider { + constructor(readonly workerManager: IWorkerManager) { + workerManager.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }); + } + + private _onDidChangeTreeData = new vscode.EventEmitter< + T | undefined | void + >(); + + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + abstract getTreeItem(element: T): vscode.TreeItem | Thenable; + + abstract getChildren(element?: T | undefined): vscode.ProviderResult; +} + +type ServerGroupState = { label: string }; +type ServerNode = ServerGroupState | ServerState; + +function isServerGroupState(node: ServerNode): node is ServerGroupState { + return 'label' in node; +} + +/** + * Provider for the server tree view. + */ +export class ServerTreeProvider extends TreeProvider { + getTreeItem( + element: ServerNode + ): vscode.TreeItem | Thenable { + if (isServerGroupState(element)) { + return { + label: element.label, + iconPath: new vscode.ThemeIcon('server'), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + }; + } + + return { + label: new URL(element.url).host, + iconPath: new vscode.ThemeIcon( + element.isRunning === true ? 'circle-large-filled' : 'circle-slash' + ), + }; + } + + getChildren(elementOrRoot?: ServerNode): vscode.ProviderResult { + // Root node + if (elementOrRoot == null) { + return [{ label: 'Running' }, { label: 'Stopped' }]; + } + + if (isServerGroupState(elementOrRoot)) { + return this.workerManager + .getServers() + .filter(server => + (elementOrRoot as ServerGroupState).label === 'Running' + ? server.isRunning + : !server.isRunning + ); + } + } +} + +/** + * Provider for the worker tree view. + */ +export class WorkerTreeProvider extends TreeProvider { + getTreeItem( + element: WorkerState + ): vscode.TreeItem | Thenable { + return { + label: new URL(element.url).host, + iconPath: new vscode.ThemeIcon('vm-connect'), + }; + } + + getChildren(): vscode.ProviderResult { + return this.workerManager.getWorkers(); + } +} diff --git a/src/services/WorkerManager.ts b/src/services/WorkerManager.ts new file mode 100644 index 00000000..034edc01 --- /dev/null +++ b/src/services/WorkerManager.ts @@ -0,0 +1,65 @@ +import * as vscode from 'vscode'; +import type { ServerState, WorkerState } from '../common'; +import { isDhcServerRunning } from '../dh/dhc'; +import { isDheServerRunning } from '../dh/dhe'; +import { IConfigService, IWorkerManager } from './types'; +import { getInitialServerStates } from '../util/workerUtils'; + +export class WorkerManager implements IWorkerManager { + private _serverMap: Map; + private _workerMap: Map; + + private _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate = this._onDidUpdate.event; + + constructor(configService: IConfigService) { + const initialDhcServerState = getInitialServerStates( + 'DHC', + configService.getCoreServers() + ); + + const initialDheServerState = getInitialServerStates( + 'DHE', + configService.getEnterpriseServers() + ); + + const initialWorkerState: WorkerState[] = []; + + this._serverMap = new Map( + [...initialDhcServerState, ...initialDheServerState].map(state => [ + state.url, + state, + ]) + ); + + this._workerMap = new Map( + initialWorkerState.map(state => [state.url, state]) + ); + } + + getServers = (): ServerState[] => { + return [...this._serverMap.values()]; + }; + + getWorkers = (): WorkerState[] => { + return [...this._workerMap.values()]; + }; + + updateStatus = async (): Promise => { + const promises = this.getServers().map(server => + (server.type === 'DHC' + ? isDhcServerRunning(server.url) + : isDheServerRunning(server.url) + ).then(isRunning => { + this._serverMap.set(server.url, { ...server, isRunning }); + if ((server.isRunning ?? false) !== isRunning) { + this._onDidUpdate.fire(); + } + }) + ); + + await Promise.all(promises); + }; + + async dispose(): Promise {} +} diff --git a/src/services/index.ts b/src/services/index.ts index a28afda3..38fcbe5d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -5,3 +5,5 @@ export * from './DhcService'; export * from './DhServiceRegistry'; export * from './ExtensionController'; export * from './RunCommandCodeLensProvider'; +export * from './TreeProvider'; +export type * from './types'; diff --git a/src/services/types.d.ts b/src/services/types.d.ts new file mode 100644 index 00000000..3a091b3e --- /dev/null +++ b/src/services/types.d.ts @@ -0,0 +1,27 @@ +import * as vscode from 'vscode'; +import type { + CoreConnectionConfig, + Disposable, + EnterpriseConnectionConfig, + ServerState, + WorkerState, +} from '../common'; + +/** + * Configuration service interface. + */ +export interface IConfigService { + getCoreServers: () => CoreConnectionConfig[]; + getEnterpriseServers: () => EnterpriseConnectionConfig[]; +} + +/** + * Worker manager interface. + */ +export interface IWorkerManager extends Disposable { + onDidUpdate: vscode.Event; + + getServers: () => ServerState[]; + getWorkers: () => WorkerState[]; + updateStatus: () => Promise; +} diff --git a/src/util/downloadUtils.ts b/src/util/downloadUtils.ts index 8627100f..fc77fa6b 100644 --- a/src/util/downloadUtils.ts +++ b/src/util/downloadUtils.ts @@ -2,8 +2,9 @@ import * as fs from 'node:fs'; import * as http from 'node:http'; import * as https from 'node:https'; import * as path from 'node:path'; -import { TMP_DIR_ROOT } from '../common'; +import { SERVER_STATUS_CHECK_INTERVAL, TMP_DIR_ROOT } from '../common'; import { Logger } from './Logger'; +import { isAggregateError } from './errorUtils'; const logger = new Logger('downloadUtils'); @@ -98,3 +99,45 @@ export async function downloadFromURL( }); }); } + +/** + * Check if a given url returns an expected status code. + */ +export async function hasStatusCode( + url: URL, + statusCode: number +): Promise { + return new Promise(resolve => { + const transporter = url.protocol === 'http:' ? http : https; + + const request = transporter + .get(url, { timeout: SERVER_STATUS_CHECK_INTERVAL }, res => { + removeListenersAndResolve(res.statusCode === statusCode); + }) + .on('timeout', () => { + removeListenersAndResolve(false); + }) + .on('error', err => { + // Expected error if the server is not running + const isServerStoppedError = isAggregateError(err, 'ECONNREFUSED'); + + if (!isServerStoppedError) { + logger.error('Error when checking:', url, err); + } + + removeListenersAndResolve(false); + }); + + /** + * Any time we resolve the Promise, remove listeners to avoid handling + * additional events and destroy the request stream to avoid any additional + * processing. + */ + function removeListenersAndResolve(value: boolean): void { + request.removeAllListeners(); + request.destroy(); + + resolve(value); + } + }); +} diff --git a/src/util/errorUtils.ts b/src/util/errorUtils.ts index b8ea1ad1..5eabdabb 100644 --- a/src/util/errorUtils.ts +++ b/src/util/errorUtils.ts @@ -12,8 +12,26 @@ export interface ParsedError { traceback?: string; } -export function isAggregateError(err: unknown): err is { code: string } { - return String(err) === 'AggregateError'; +/** + * Returns true if the given error is an AggregateError. Optionally checks if + * a given code matches the error's code. + * @param err Error to check + * @param code Optional code to check + */ +export function isAggregateError( + err: unknown, + code?: string +): err is { code: string } { + if ( + err != null && + typeof err === 'object' && + 'code' in err && + String(err) === 'AggregateError' + ) { + return code == null || err.code === code; + } + + return false; } /** diff --git a/src/util/workerUtils.ts b/src/util/workerUtils.ts new file mode 100644 index 00000000..727778d8 --- /dev/null +++ b/src/util/workerUtils.ts @@ -0,0 +1,11 @@ +import { ServerState, ServerType } from '../common'; + +export function getInitialServerStates( + type: ServerType, + configs: { url: string }[] +): ServerState[] { + return configs.map(config => ({ + type, + url: config.url, + })); +}