From 478bc92d999eb31b0809a3f6bc943760a9c7087c Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 09:28:11 +0100 Subject: [PATCH 1/7] Allow connecting to remote servers --- .readthedocs.yml | 4 +- README.md | 40 +++++++ docs/jupyter-lite.json | 12 ++ schema/config.json | 15 +++ schema/plugin.json | 8 -- src/config.ts | 165 +++++++++++++++++++++++++++ src/dialogs.ts | 193 ++++++++++++++++++++++++++++++++ src/index.ts | 248 +++++++++++++++++++++++++++++++++-------- src/kernel.ts | 47 ++++++-- src/kernelclient.ts | 184 ++++++++++++++++++++++++++++++ src/kernelspec.ts | 186 +++++++++++++++++++++++++++++-- src/session.ts | 62 ++++++++++- src/tokens.ts | 56 ++++++++++ style/base.css | 70 ++++++++++++ 14 files changed, 1212 insertions(+), 78 deletions(-) create mode 100644 docs/jupyter-lite.json create mode 100644 schema/config.json delete mode 100644 schema/plugin.json create mode 100644 src/config.ts create mode 100644 src/dialogs.ts create mode 100644 src/kernelclient.ts create mode 100644 src/tokens.ts diff --git a/.readthedocs.yml b/.readthedocs.yml index 6c9b48c..043fa9f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,6 +7,6 @@ build: commands: - mamba env update --name base --file docs/environment.yml - python -m pip install . - - jupyter lite build --output-dir dist + - cd docs && jupyter lite build --output-dir dist - mkdir -p $READTHEDOCS_OUTPUT/html - - cp -r dist/* $READTHEDOCS_OUTPUT/html/ + - cp -r docs/dist/* $READTHEDOCS_OUTPUT/html/ diff --git a/README.md b/README.md index 7dbeb32..848fc7c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,46 @@ This extension lets you use in-browser kernels (like Pyodide) and regular Jupyte > [!NOTE] > While regular Jupyter kernels can be used across tabs and persist after reloading the page, in-browser kernels are only available on the page or browser tab where they were started, and destroyed on page reload. +### Operating Modes + +This extension supports two operating modes, configured via the `hybridKernelsMode` PageConfig option: + +#### Hybrid Mode (default) + +In hybrid mode (`hybridKernelsMode: 'hybrid'`), the extension shows: + +- Kernels from the local Jupyter server (e.g., Python, R) +- In-browser lite kernels (e.g., Pyodide) + +This is the default mode when running JupyterLab with a local Jupyter server. No additional configuration is needed. + +#### Remote Mode + +In remote mode (`hybridKernelsMode: 'remote'`), the extension shows: + +- In-browser lite kernels +- Optionally, kernels from a remote Jupyter server (configured via the "Configure Remote Jupyter Server" command) + +This mode is designed for JupyterLite or similar environments where there's no local Jupyter server. To enable remote mode, set the PageConfig option: + +```html + +``` + +Or in `jupyter-lite.json`: + +```json +{ + "jupyter-config-data": { + "hybridKernelsMode": "remote" + } +} +``` + ### File system access from in-browser kernels In-browser kernels like Pyodide (via `jupyterlite-pyodide-kernel`) can access the files shown in the JupyterLab file browser. diff --git a/docs/jupyter-lite.json b/docs/jupyter-lite.json new file mode 100644 index 0000000..2968bbf --- /dev/null +++ b/docs/jupyter-lite.json @@ -0,0 +1,12 @@ +{ + "jupyter-lite-schema-version": 0, + "jupyter-config-data": { + "appName": "Hybrid Kernels Demo", + "hybridKernelsMode": "remote", + "disabledExtensions": [ + "@jupyterlite/services-extension:kernel-manager", + "@jupyterlite/services-extension:kernel-spec-manager", + "@jupyterlite/services-extension:session-manager" + ] + } +} diff --git a/schema/config.json b/schema/config.json new file mode 100644 index 0000000..48c52dc --- /dev/null +++ b/schema/config.json @@ -0,0 +1,15 @@ +{ + "jupyter.lab.shortcuts": [], + "jupyter.lab.toolbars": { + "TopBar": [ + { + "name": "remote-server-status", + "rank": 40 + } + ] + }, + "title": "Hybrid Kernels", + "description": "Settings for hybrid kernels extension", + "type": "object", + "properties": {} +} diff --git a/schema/plugin.json b/schema/plugin.json deleted file mode 100644 index c90e636..0000000 --- a/schema/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "jupyter.lab.shortcuts": [], - "title": "jupyterlab-hybrid-kernels", - "description": "jupyterlab-hybrid-kernels settings.", - "type": "object", - "properties": {}, - "additionalProperties": false -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6fba1c6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,165 @@ +import { ServerConnection } from '@jupyterlab/services'; +import { PageConfig } from '@jupyterlab/coreutils'; +import { Signal } from '@lumino/signaling'; +import type { ISignal } from '@lumino/signaling'; + +import type { IRemoteServerConfig, HybridKernelsMode } from './tokens'; + +/** + * PageConfig keys for hybrid kernels configuration + */ +const PAGE_CONFIG_BASE_URL_KEY = 'hybridKernelsBaseUrl'; +const PAGE_CONFIG_TOKEN_KEY = 'hybridKernelsToken'; +const PAGE_CONFIG_MODE_KEY = 'hybridKernelsMode'; + +/** + * Get the current hybrid kernels mode from PageConfig. + * Defaults to 'hybrid' if not configured. + */ +export function getHybridKernelsMode(): HybridKernelsMode { + const mode = PageConfig.getOption(PAGE_CONFIG_MODE_KEY); + if (mode === 'remote') { + return 'remote'; + } + return 'hybrid'; +} + +/** + * Implementation of remote server configuration. + * Always reads from and writes to PageConfig, acting as a proxy. + */ +export class RemoteServerConfig implements IRemoteServerConfig { + /** + * Get the base URL from PageConfig + */ + get baseUrl(): string { + return PageConfig.getOption(PAGE_CONFIG_BASE_URL_KEY); + } + + /** + * Get the token from PageConfig + */ + get token(): string { + return PageConfig.getOption(PAGE_CONFIG_TOKEN_KEY); + } + + /** + * Whether we are currently connected to the remote server + */ + get isConnected(): boolean { + return this._isConnected; + } + + /** + * A signal emitted when the configuration changes. + */ + get changed(): ISignal { + return this._changed; + } + + /** + * Set the connection state + */ + setConnected(connected: boolean): void { + if (this._isConnected !== connected) { + this._isConnected = connected; + this._changed.emit(); + } + } + + /** + * Update the configuration by writing to PageConfig. + * The new values will be immediately available via the getters. + */ + update(config: { baseUrl?: string; token?: string }): void { + let hasChanged = false; + const currentBaseUrl = this.baseUrl; + const currentToken = this.token; + + if (config.baseUrl !== undefined && config.baseUrl !== currentBaseUrl) { + PageConfig.setOption(PAGE_CONFIG_BASE_URL_KEY, config.baseUrl); + hasChanged = true; + } + + if (config.token !== undefined && config.token !== currentToken) { + PageConfig.setOption(PAGE_CONFIG_TOKEN_KEY, config.token); + hasChanged = true; + } + + if (hasChanged) { + this._changed.emit(); + } + } + + private _changed = new Signal(this); + private _isConnected = false; +} + +/** + * Create dynamic server settings that read from PageConfig on every access. + * This ensures that when the user updates the configuration via the dialog, + * subsequent API calls will use the new values without needing to recreate managers. + * + * The returned object implements ServerConnection.ISettings with dynamic getters + * for baseUrl, wsUrl, and token that always read the current values from PageConfig. + */ +export function createServerSettings(): ServerConnection.ISettings { + const defaultSettings = ServerConnection.makeSettings(); + + const dynamicSettings: ServerConnection.ISettings = { + get baseUrl(): string { + const baseUrl = PageConfig.getOption(PAGE_CONFIG_BASE_URL_KEY); + if (!baseUrl) { + return defaultSettings.baseUrl; + } + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + }, + + get appUrl(): string { + return defaultSettings.appUrl; + }, + + get wsUrl(): string { + const baseUrl = PageConfig.getOption(PAGE_CONFIG_BASE_URL_KEY); + if (!baseUrl) { + return defaultSettings.wsUrl; + } + const wsUrl = baseUrl.replace(/^http/, 'ws'); + return wsUrl.endsWith('/') ? wsUrl : `${wsUrl}/`; + }, + + get token(): string { + return PageConfig.getOption(PAGE_CONFIG_TOKEN_KEY); + }, + + get init(): RequestInit { + return defaultSettings.init; + }, + + get Headers(): typeof Headers { + return defaultSettings.Headers; + }, + + get Request(): typeof Request { + return defaultSettings.Request; + }, + + get fetch(): ServerConnection.ISettings['fetch'] { + return defaultSettings.fetch; + }, + + get WebSocket(): typeof WebSocket { + return defaultSettings.WebSocket; + }, + + get appendToken(): boolean { + return true; + }, + + get serializer(): ServerConnection.ISettings['serializer'] { + return defaultSettings.serializer; + } + }; + + return dynamicSettings; +} diff --git a/src/dialogs.ts b/src/dialogs.ts new file mode 100644 index 0000000..e0b3bf3 --- /dev/null +++ b/src/dialogs.ts @@ -0,0 +1,193 @@ +import type { Dialog } from '@jupyterlab/apputils'; +import { Widget } from '@lumino/widgets'; + +/** + * Interface for the remote server configuration form values. + */ +export interface IRemoteServerFormValue { + baseUrl: string; + token: string; +} + +/** + * Parse a full JupyterHub/Binder URL to extract the base URL and token. + * Handles URLs like: + * https://hub.2i2c.mybinder.org/user/jupyterlab-jupyterlab-demo-7r632cge/lab/tree/demo?token=AMJL4AzxSeOAnv0F7gHsKQ + * + * @param fullUrl The full URL that may contain /lab/, /tree/, or /notebooks/ paths and a token query param + * @returns An object with baseUrl and token, or null if parsing fails + */ +function parseJupyterUrl( + fullUrl: string +): { baseUrl: string; token: string } | null { + try { + const url = new URL(fullUrl); + + // Extract the token from query parameters + const token = url.searchParams.get('token') ?? ''; + + // Find the base URL by removing common Jupyter paths + // Common patterns: /lab, /tree, /notebooks, /edit, /terminals, /consoles + const pathname = url.pathname; + const jupyterPathPattern = + /\/(lab|tree|notebooks|edit|terminals|consoles|doc)(\/|$)/; + const match = pathname.match(jupyterPathPattern); + + let basePath: string; + if (match) { + // Cut off everything from the Jupyter path onwards + basePath = pathname.substring(0, match.index); + } else { + // No recognized Jupyter path, use the full pathname + basePath = pathname; + } + + // Ensure basePath ends without trailing slash for consistency + basePath = basePath.replace(/\/$/, ''); + + const baseUrl = `${url.protocol}//${url.host}${basePath}`; + + return { baseUrl, token }; + } catch { + return null; + } +} + +/** + * Widget body for the remote server configuration dialog. + * Follows the JupyterLab pattern from InputDialogBase. + */ +export class RemoteServerConfigBody + extends Widget + implements Dialog.IBodyWidget +{ + constructor(options: RemoteServerConfigBody.IOptions) { + super(); + this.addClass('jp-HybridKernels-configDialog'); + + // Full URL input section + const fullUrlSection = document.createElement('div'); + fullUrlSection.className = 'jp-HybridKernels-formSection'; + + const fullUrlLabel = document.createElement('label'); + fullUrlLabel.className = 'jp-HybridKernels-label'; + fullUrlLabel.textContent = 'Paste Full URL (with token)'; + fullUrlLabel.htmlFor = 'hybrid-kernels-full-url'; + fullUrlSection.appendChild(fullUrlLabel); + + this._fullUrlInput = document.createElement('input'); + this._fullUrlInput.type = 'text'; + this._fullUrlInput.id = 'hybrid-kernels-full-url'; + this._fullUrlInput.className = 'jp-mod-styled jp-HybridKernels-input'; + this._fullUrlInput.placeholder = + 'https://hub.example.org/user/name/lab?token=...'; + fullUrlSection.appendChild(this._fullUrlInput); + + const fullUrlHelp = document.createElement('small'); + fullUrlHelp.className = 'jp-HybridKernels-help'; + fullUrlHelp.textContent = + 'Paste a full JupyterHub/Binder URL to auto-fill the fields below'; + fullUrlSection.appendChild(fullUrlHelp); + + this.node.appendChild(fullUrlSection); + + // Separator + const separator = document.createElement('hr'); + separator.className = 'jp-HybridKernels-separator'; + this.node.appendChild(separator); + + // Server URL input section + const serverUrlSection = document.createElement('div'); + serverUrlSection.className = 'jp-HybridKernels-formSection'; + + const serverUrlLabel = document.createElement('label'); + serverUrlLabel.className = 'jp-HybridKernels-label'; + serverUrlLabel.textContent = 'Server URL'; + serverUrlLabel.htmlFor = 'hybrid-kernels-server-url'; + serverUrlSection.appendChild(serverUrlLabel); + + this._serverUrlInput = document.createElement('input'); + this._serverUrlInput.type = 'text'; + this._serverUrlInput.id = 'hybrid-kernels-server-url'; + this._serverUrlInput.className = 'jp-mod-styled jp-HybridKernels-input'; + this._serverUrlInput.placeholder = 'https://example.com/jupyter'; + this._serverUrlInput.value = options.baseUrl; + serverUrlSection.appendChild(this._serverUrlInput); + + this.node.appendChild(serverUrlSection); + + // Token input section + const tokenSection = document.createElement('div'); + tokenSection.className = 'jp-HybridKernels-formSection'; + + const tokenLabel = document.createElement('label'); + tokenLabel.className = 'jp-HybridKernels-label'; + tokenLabel.textContent = 'Authentication Token'; + tokenLabel.htmlFor = 'hybrid-kernels-token'; + tokenSection.appendChild(tokenLabel); + + this._tokenInput = document.createElement('input'); + this._tokenInput.type = 'password'; + this._tokenInput.id = 'hybrid-kernels-token'; + this._tokenInput.className = 'jp-mod-styled jp-HybridKernels-input'; + this._tokenInput.placeholder = 'Enter token (optional)'; + this._tokenInput.value = options.token; + tokenSection.appendChild(this._tokenInput); + + this.node.appendChild(tokenSection); + + // Set up event handlers for auto-fill from full URL + this._fullUrlInput.addEventListener('input', this._handleFullUrlChange); + this._fullUrlInput.addEventListener('paste', () => { + setTimeout(this._handleFullUrlChange, 0); + }); + } + + /** + * Get the form values. + */ + getValue(): IRemoteServerFormValue { + return { + baseUrl: this._serverUrlInput.value, + token: this._tokenInput.value + }; + } + + /** + * Handle changes to the full URL input. + */ + private _handleFullUrlChange = (): void => { + const fullUrl = this._fullUrlInput.value.trim(); + if (fullUrl) { + const parsed = parseJupyterUrl(fullUrl); + if (parsed) { + this._serverUrlInput.value = parsed.baseUrl; + this._tokenInput.value = parsed.token; + } + } + }; + + private _fullUrlInput: HTMLInputElement; + private _serverUrlInput: HTMLInputElement; + private _tokenInput: HTMLInputElement; +} + +/** + * A namespace for RemoteServerConfigBody statics. + */ +export namespace RemoteServerConfigBody { + /** + * The options used to create a RemoteServerConfigBody. + */ + export interface IOptions { + /** + * The initial base URL value. + */ + baseUrl: string; + + /** + * The initial token value. + */ + token: string; + } +} diff --git a/src/index.ts b/src/index.ts index 0128afd..3a2e4f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,18 +6,27 @@ import type { import type { Kernel, KernelSpec, - ServerConnection, ServiceManagerPlugin, Session } from '@jupyterlab/services'; import { IKernelManager, IKernelSpecManager, - IServerSettings, ISessionManager } from '@jupyterlab/services'; -import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { + Dialog, + showDialog, + ICommandPalette, + IToolbarWidgetRegistry +} from '@jupyterlab/apputils'; + +import { URLExt } from '@jupyterlab/coreutils'; + +import { linkIcon } from '@jupyterlab/ui-components'; + +import { Widget } from '@lumino/widgets'; import { IKernelClient, @@ -28,39 +37,194 @@ import { import { HybridKernelManager } from './kernel'; +import { RemoteServerConfigBody } from './dialogs'; + import { HybridKernelSpecManager } from './kernelspec'; import { HybridSessionManager } from './session'; +import { IRemoteServerConfig } from './tokens'; + +import { + RemoteServerConfig, + createServerSettings, + getHybridKernelsMode +} from './config'; + +/** + * Command ID for configuring the remote server + */ +const CommandIds = { + configureRemoteServer: 'hybrid-kernels:configure-remote-server' +}; + +/** + * Remote server configuration provider plugin. + * Provides configuration that reads from/writes to PageConfig. + */ +const configPlugin: ServiceManagerPlugin = { + id: 'jupyterlab-hybrid-kernels:config', + description: 'Remote server configuration provider', + autoStart: true, + provides: IRemoteServerConfig, + activate: (_: null): IRemoteServerConfig => { + return new RemoteServerConfig(); + } +}; + +/** + * A custom toolbar widget that shows the remote server connection status. + */ +class RemoteServerStatusWidget extends Widget { + constructor(options: RemoteServerStatusWidget.IOptions) { + super(); + this._app = options.app; + this._remoteConfig = options.remoteConfig; + this._commandId = options.commandId; + this.addClass('jp-HybridKernels-status'); + this._updateStatus(); + + this._remoteConfig.changed.connect(this._updateStatus, this); + + this.node.style.cursor = 'pointer'; + this.node.addEventListener('click', () => { + void this._app.commands.execute(this._commandId); + }); + } + + /** + * Dispose of the resources held by the widget. + */ + dispose(): void { + this._remoteConfig.changed.disconnect(this._updateStatus, this); + super.dispose(); + } + + /** + * Update the status display. + */ + private _updateStatus(): void { + this.removeClass('jp-HybridKernels-connected'); + this.removeClass('jp-HybridKernels-disconnected'); + + if (this._remoteConfig.isConnected) { + this.addClass('jp-HybridKernels-connected'); + } else { + this.addClass('jp-HybridKernels-disconnected'); + } + + this.node.innerHTML = ''; + linkIcon.element({ container: this.node }); + } + + private _app: JupyterFrontEnd; + private _remoteConfig: IRemoteServerConfig; + private _commandId: string; +} + +/** + * A namespace for RemoteServerStatusWidget statics. + */ +namespace RemoteServerStatusWidget { + /** + * Options for creating a RemoteServerStatusWidget. + */ + export interface IOptions { + /** + * The application instance. + */ + app: JupyterFrontEnd; + + /** + * The remote server configuration. + */ + remoteConfig: IRemoteServerConfig; + + /** + * The command ID to execute when clicked. + */ + commandId: string; + } +} + /** - * Initialization data for the jupyterlab-hybrid-kernels extension. + * Plugin that adds a command to configure the remote server via a dialog. */ -const plugin: JupyterFrontEndPlugin = { - id: 'jupyterlab-hybrid-kernels:plugin', - description: 'Use in-browser and regular kernels in JupyterLab', +const configDialogPlugin: JupyterFrontEndPlugin = { + id: 'jupyterlab-hybrid-kernels:config-dialog', + description: 'Provides a dialog to configure the remote server', autoStart: true, - optional: [ISettingRegistry], + requires: [IRemoteServerConfig, IKernelSpecManager, IToolbarWidgetRegistry], + optional: [ICommandPalette], activate: ( app: JupyterFrontEnd, - settingRegistry: ISettingRegistry | null - ) => { - console.log('JupyterLab extension jupyterlab-hybrid-kernels is activated!'); - - if (settingRegistry) { - settingRegistry - .load(plugin.id) - .then(settings => { - console.log( - 'jupyterlab-hybrid-kernels settings loaded:', - settings.composite - ); - }) - .catch(reason => { - console.error( - 'Failed to load settings for jupyterlab-hybrid-kernels.', - reason - ); + remoteConfig: IRemoteServerConfig, + kernelSpecManager: KernelSpec.IManager, + toolbarRegistry: IToolbarWidgetRegistry, + palette: ICommandPalette | null + ): void => { + const isRemoteMode = getHybridKernelsMode() === 'remote'; + + if (isRemoteMode) { + toolbarRegistry.addFactory('TopBar', 'remote-server-status', () => { + return new RemoteServerStatusWidget({ + app, + remoteConfig, + commandId: CommandIds.configureRemoteServer + }); + }); + } + + app.commands.addCommand(CommandIds.configureRemoteServer, { + label: 'Configure Remote Jupyter Server', + caption: 'Configure the remote Jupyter server connection', + icon: linkIcon, + isVisible: () => isRemoteMode, + execute: async () => { + const body = new RemoteServerConfigBody({ + baseUrl: remoteConfig.baseUrl, + token: remoteConfig.token + }); + + const result = await showDialog({ + title: 'Remote Server Configuration', + body, + buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Save' })], + focusNodeSelector: 'input' }); + + if (!result.button.accept || !result.value) { + return; + } + + const { baseUrl, token } = result.value; + + remoteConfig.update({ baseUrl, token }); + + if (baseUrl) { + try { + const testUrl = URLExt.join(baseUrl, 'api/kernelspecs'); + const urlWithToken = token + ? `${testUrl}?token=${encodeURIComponent(token)}` + : testUrl; + const response = await fetch(urlWithToken); + remoteConfig.setConnected(response.ok); + } catch { + remoteConfig.setConnected(false); + } + } else { + remoteConfig.setConnected(false); + } + + await kernelSpecManager.refreshSpecs(); + } + }); + + if (palette) { + palette.addItem({ + command: CommandIds.configureRemoteServer, + category: 'Kernel' + }); } } }; @@ -73,13 +237,9 @@ const kernelClientPlugin: ServiceManagerPlugin = { description: 'The client for managing in-browser kernels', autoStart: true, requires: [IKernelSpecs], - optional: [IServerSettings], provides: IKernelClient, - activate: ( - _: null, - kernelSpecs: IKernelSpecs, - serverSettings?: ServerConnection.ISettings - ): IKernelClient => { + activate: (_: null, kernelSpecs: IKernelSpecs): IKernelClient => { + const serverSettings = createServerSettings(); return new LiteKernelClient({ kernelSpecs, serverSettings }); } }; @@ -93,14 +253,12 @@ const kernelManagerPlugin: ServiceManagerPlugin = { autoStart: true, provides: IKernelManager, requires: [IKernelClient, IKernelSpecs], - optional: [IServerSettings], activate: ( _: null, kernelClient: IKernelClient, - kernelSpecs: IKernelSpecs, - serverSettings: ServerConnection.ISettings | undefined + kernelSpecs: IKernelSpecs ): Kernel.IManager => { - console.log('Using the HybridKernelManager'); + const serverSettings = createServerSettings(); return new HybridKernelManager({ kernelClient, kernelSpecs, @@ -118,13 +276,8 @@ const kernelSpecManagerPlugin: ServiceManagerPlugin = { autoStart: true, provides: IKernelSpecManager, requires: [IKernelSpecs], - optional: [IServerSettings], - activate: ( - _: null, - kernelSpecs: IKernelSpecs, - serverSettings: ServerConnection.ISettings | undefined - ): KernelSpec.IManager => { - console.log('Using HybridKernelSpecManager'); + activate: (_: null, kernelSpecs: IKernelSpecs): KernelSpec.IManager => { + const serverSettings = createServerSettings(); const manager = new HybridKernelSpecManager({ kernelSpecs, serverSettings @@ -156,15 +309,13 @@ const sessionManagerPlugin: ServiceManagerPlugin = { autoStart: true, provides: ISessionManager, requires: [IKernelClient, IKernelManager, IKernelSpecs], - optional: [IServerSettings], activate: ( _: null, kernelClient: IKernelClient, kernelManager: Kernel.IManager, - kernelSpecs: IKernelSpecs, - serverSettings: ServerConnection.ISettings | undefined + kernelSpecs: IKernelSpecs ): Session.IManager => { - console.log('Using the HybridSessionManager'); + const serverSettings = createServerSettings(); return new HybridSessionManager({ kernelClient, kernelManager, @@ -175,7 +326,8 @@ const sessionManagerPlugin: ServiceManagerPlugin = { }; const plugins = [ - plugin, + configPlugin, + configDialogPlugin, kernelClientPlugin, kernelManagerPlugin, kernelSpecManagerPlugin, diff --git a/src/kernel.ts b/src/kernel.ts index 554364a..333dc25 100644 --- a/src/kernel.ts +++ b/src/kernel.ts @@ -12,10 +12,17 @@ import { Signal } from '@lumino/signaling'; import { WebSocket } from 'mock-socket'; +/** + * A hybrid kernel manager that combines in-browser (lite) kernels + * with remote server kernels. + */ export class HybridKernelManager extends BaseManager implements Kernel.IManager { + /** + * Construct a new hybrid kernel manager. + */ constructor(options: HybridKernelManager.IOptions) { super(options); @@ -28,14 +35,12 @@ export class HybridKernelManager this._liteKernelManager = new KernelManager({ serverSettings: { ...ServerConnection.makeSettings(), - ...serverSettings, WebSocket }, kernelAPIClient: kernelClient }); this._liteKernelSpecs = kernelSpecs; - // forward running changed signals this._liteKernelManager.runningChanged.connect((sender, _) => { const running = Array.from(this.running()); this._runningChanged.emit(running); @@ -46,6 +51,9 @@ export class HybridKernelManager }); } + /** + * Dispose of the resources used by the manager. + */ dispose(): void { this._kernelManager.dispose(); this._liteKernelManager.dispose(); @@ -58,6 +66,7 @@ export class HybridKernelManager get connectionFailure(): ISignal { return this._connectionFailure; } + /** * Test whether the manager is ready. */ @@ -75,10 +84,16 @@ export class HybridKernelManager ]).then(() => {}); } + /** + * A signal emitted when the running kernels change. + */ get runningChanged(): ISignal { return this._runningChanged; } + /** + * Connect to a running kernel. + */ connectTo( options: Kernel.IKernelConnection.IOptions ): Kernel.IKernelConnection { @@ -89,6 +104,9 @@ export class HybridKernelManager return this._kernelManager.connectTo(options); } + /** + * Create an iterator over the running kernels. + */ running(): IterableIterator { const kernelManager = this._kernelManager; const liteKernelManager = this._liteKernelManager; @@ -99,10 +117,16 @@ export class HybridKernelManager return combinedRunning(); } + /** + * The number of running kernels. + */ get runningCount(): number { return Array.from(this.running()).length; } + /** + * Force a refresh of the running kernels. + */ async refreshRunning(): Promise { await Promise.all([ this._kernelManager.refreshRunning(), @@ -110,6 +134,9 @@ export class HybridKernelManager ]); } + /** + * Start a new kernel. + */ async startNew( createOptions: Kernel.IKernelOptions = {}, connectOptions: Omit< @@ -124,6 +151,9 @@ export class HybridKernelManager return this._kernelManager.startNew(createOptions, connectOptions); } + /** + * Shut down a kernel by id. + */ async shutdown(id: string): Promise { if (this._isLiteKernel({ id })) { return this._liteKernelManager.shutdown(id); @@ -131,6 +161,9 @@ export class HybridKernelManager return this._kernelManager.shutdown(id); } + /** + * Shut down all kernels. + */ async shutdownAll(): Promise { await Promise.all([ this._kernelManager.shutdownAll(), @@ -138,6 +171,9 @@ export class HybridKernelManager ]); } + /** + * Find a kernel by id. + */ async findById(id: string): Promise { const kernel = await this._kernelManager.findById(id); if (kernel) { @@ -169,18 +205,13 @@ export namespace HybridKernelManager { * The options used to initialize a kernel manager. */ export interface IOptions extends BaseManager.IOptions { - /** - * The server settings used by the kernel manager. - */ - serverSettings?: ServerConnection.ISettings; - /** * The in-browser kernel client. */ kernelClient: IKernelClient; /** - * The lite kernel specs + * The lite kernel specs. */ kernelSpecs: IKernelSpecs; } diff --git a/src/kernelclient.ts b/src/kernelclient.ts new file mode 100644 index 0000000..0628ac1 --- /dev/null +++ b/src/kernelclient.ts @@ -0,0 +1,184 @@ +import type { + Kernel, + KernelMessage, + ServerConnection +} from '@jupyterlab/services'; +import { KernelAPI } from '@jupyterlab/services'; +import type { + IKernel, + IKernelClient, + IKernelSpecs, + LiteKernelClient +} from '@jupyterlite/services'; +import type { IObservableMap } from '@jupyterlab/observables'; +import type { ISignal } from '@lumino/signaling'; +import { Signal } from '@lumino/signaling'; + +/** + * A hybrid kernel client that routes kernel operations to either + * lite or remote kernel clients based on the kernel name. + */ +export class HybridKernelClient implements IKernelClient { + constructor(options: HybridKernelClient.IOptions) { + this._liteKernelClient = options.liteKernelClient; + this._liteKernelSpecs = options.kernelSpecs; + this._serverSettings = options.serverSettings; + + this._liteKernelClient.changed.connect((_, args) => { + if (args.type === 'add' && args.newValue) { + this._liteKernelIds.add(args.newValue.id); + } else if (args.type === 'remove' && args.oldValue) { + this._liteKernelIds.delete(args.oldValue.id); + } + this._changed.emit(args); + }); + } + + /** + * The server settings. + */ + get serverSettings(): ServerConnection.ISettings { + return this._serverSettings; + } + + /** + * Signal emitted when the kernels map changes + */ + get changed(): ISignal> { + return this._changed; + } + + /** + * Start a new kernel. + * + * Routes to lite or remote kernel client based on the kernel name. + */ + async startNew( + options: LiteKernelClient.IKernelOptions = {} + ): Promise { + const { name } = options; + if (name && this._liteKernelSpecs.specs?.kernelspecs[name]) { + return this._liteKernelClient.startNew(options); + } + return KernelAPI.startNew({ name: name ?? '' }, this._serverSettings); + } + + /** + * Restart a kernel. + */ + async restart(kernelId: string): Promise { + if (this._isLiteKernel(kernelId)) { + return this._liteKernelClient.restart(kernelId); + } + await KernelAPI.restartKernel(kernelId, this._serverSettings); + } + + /** + * Interrupt a kernel. + */ + async interrupt(kernelId: string): Promise { + if (this._isLiteKernel(kernelId)) { + return this._liteKernelClient.interrupt(kernelId); + } + await KernelAPI.interruptKernel(kernelId, this._serverSettings); + } + + /** + * List running kernels. + */ + async listRunning(): Promise { + const liteKernels = await this._liteKernelClient.listRunning(); + try { + const remoteKernels = await KernelAPI.listRunning(this._serverSettings); + return [...liteKernels, ...remoteKernels]; + } catch { + // Remote server might not be available + return liteKernels; + } + } + + /** + * Shut down a kernel. + */ + async shutdown(id: string): Promise { + if (this._isLiteKernel(id)) { + return this._liteKernelClient.shutdown(id); + } + await KernelAPI.shutdownKernel(id, this._serverSettings); + } + + /** + * Shut down all kernels. + */ + async shutdownAll(): Promise { + await this._liteKernelClient.shutdownAll(); + try { + const remoteKernels = await KernelAPI.listRunning(this._serverSettings); + await Promise.all( + remoteKernels.map(k => + KernelAPI.shutdownKernel(k.id, this._serverSettings) + ) + ); + } catch { + // Remote server might not be available + } + } + + /** + * Get a kernel model by id. + */ + async getModel(id: string): Promise { + const liteKernel = await this._liteKernelClient.getModel(id); + if (liteKernel) { + return liteKernel; + } + return undefined; + } + + /** + * Handle stdin request received from Service Worker. + */ + async handleStdin( + inputRequest: KernelMessage.IInputRequestMsg + ): Promise { + return this._liteKernelClient.handleStdin(inputRequest); + } + + /** + * Check if a kernel ID corresponds to a lite kernel. + */ + private _isLiteKernel(id: string): boolean { + return this._liteKernelIds.has(id); + } + + /** + * Track lite kernel IDs for quick lookup. + */ + private _liteKernelIds = new Set(); + + private _liteKernelClient: LiteKernelClient; + private _liteKernelSpecs: IKernelSpecs; + private _serverSettings: ServerConnection.ISettings; + private _changed = new Signal>( + this + ); +} + +export namespace HybridKernelClient { + export interface IOptions { + /** + * The lite kernel client for in-browser kernels. + */ + liteKernelClient: LiteKernelClient; + + /** + * The in-browser kernel specs. + */ + kernelSpecs: IKernelSpecs; + + /** + * The server settings for remote kernels. + */ + serverSettings: ServerConnection.ISettings; + } +} diff --git a/src/kernelspec.ts b/src/kernelspec.ts index a48bcc1..edcd5dd 100644 --- a/src/kernelspec.ts +++ b/src/kernelspec.ts @@ -1,18 +1,31 @@ import type { KernelSpec, ServerConnection } from '@jupyterlab/services'; import { BaseManager, KernelSpecManager } from '@jupyterlab/services'; +import { URLExt } from '@jupyterlab/coreutils'; + import type { IKernelSpecs } from '@jupyterlite/services'; import { LiteKernelSpecClient } from '@jupyterlite/services'; +import { Poll } from '@lumino/polling'; import type { ISignal } from '@lumino/signaling'; import { Signal } from '@lumino/signaling'; +import { getHybridKernelsMode } from './config'; + +/** + * A hybrid kernel spec manager that combines in-browser (lite) kernel specs + * with remote server kernel specs. + */ export class HybridKernelSpecManager extends BaseManager implements KernelSpec.IManager { + /** + * Construct a new hybrid kernel spec manager. + */ constructor(options: HybridKernelSpecManager.IOptions) { super(options); + this._serverSettings = options.serverSettings; this._kernelSpecManager = new KernelSpecManager({ serverSettings: options.serverSettings }); @@ -25,10 +38,35 @@ export class HybridKernelSpecManager kernelSpecAPIClient, serverSettings }); - // lite kernels specs may be added late in the plugin activation process + kernelSpecs.changed.connect(() => { this.refreshSpecs(); }); + + this._ready = Promise.all([this.refreshSpecs()]) + .then(_ => undefined) + .catch(_ => undefined) + .then(() => { + if (this.isDisposed) { + return; + } + this._isReady = true; + }); + + this._pollSpecs = new Poll({ + auto: false, + factory: () => this.refreshSpecs(), + frequency: { + interval: 10 * 1000, // Poll every 10 seconds (instead of default 61 seconds) + backoff: true, + max: 300 * 1000 + }, + name: '@jupyterlab-hybrid-kernels:HybridKernelSpecManager#specs', + standby: options.standby ?? 'when-hidden' + }); + void this._ready.then(() => { + void this._pollSpecs.start(); + }); } /** @@ -52,6 +90,14 @@ export class HybridKernelSpecManager return this._ready; } + /** + * Dispose of the resources used by the manager. + */ + dispose(): void { + this._pollSpecs.dispose(); + super.dispose(); + } + /** * Get the kernel specs. */ @@ -70,46 +116,166 @@ export class HybridKernelSpecManager * Force a refresh of the specs from the server. */ async refreshSpecs(): Promise { - await this._kernelSpecManager.refreshSpecs(); + const mode = getHybridKernelsMode(); + const serverSettings = this._kernelSpecManager.serverSettings; + const baseUrl = serverSettings.baseUrl; + + let serverSpecs: KernelSpec.ISpecModels | null = null; + + if (mode === 'hybrid') { + try { + await this._kernelSpecManager.refreshSpecs(); + serverSpecs = this._kernelSpecManager.specs; + } catch (e) { + // Silently ignore errors fetching local server specs + } + } else { + const isRemoteConfigured = !!baseUrl; + + if (isRemoteConfigured) { + const token = serverSettings.token; + const specsUrl = URLExt.join(baseUrl, 'api/kernelspecs'); + const urlWithToken = token + ? `${specsUrl}?token=${encodeURIComponent(token)}` + : specsUrl; + try { + const response = await fetch(urlWithToken); + if (response.ok) { + const data = await response.json(); + serverSpecs = data as KernelSpec.ISpecModels; + } + } catch (e) { + // Silently ignore errors fetching remote specs + } + } + } + await this._liteKernelSpecManager.refreshSpecs(); - const newSpecs = this._kernelSpecManager.specs; const newLiteSpecs = this._liteKernelSpecManager.specs; - if (!newSpecs && !newLiteSpecs) { + + if (!serverSpecs && !newLiteSpecs) { return; } + + const transformedServerSpecs = + mode === 'remote' + ? this._transformRemoteSpecResources(serverSpecs) + : serverSpecs; + const specs: KernelSpec.ISpecModels = { - default: newSpecs?.default ?? newLiteSpecs?.default ?? '', + default: serverSpecs?.default ?? newLiteSpecs?.default ?? '', kernelspecs: { - ...newSpecs?.kernelspecs, + ...transformedServerSpecs?.kernelspecs, ...newLiteSpecs?.kernelspecs } }; + this._specs = specs; this._specsChanged.emit(specs); } + /** + * Transform remote kernel spec resources to use absolute URLs. + * Also handles the nested 'spec' structure from the Jupyter Server API. + */ + private _transformRemoteSpecResources( + specs: KernelSpec.ISpecModels | null + ): KernelSpec.ISpecModels | null { + if (!specs || !this._serverSettings) { + return specs; + } + + const { baseUrl, token } = this._serverSettings; + const transformedKernelspecs: { + [key: string]: KernelSpec.ISpecModel; + } = {}; + + for (const [name, rawSpec] of Object.entries(specs.kernelspecs)) { + if (!rawSpec) { + continue; + } + + // Handle both flat and nested spec structures + // Jupyter Server API returns: { name, spec: { display_name, ... }, resources } + // ISpecModel expects: { name, display_name, ..., resources } + const spec = (rawSpec as any).spec ?? rawSpec; + const resources = (rawSpec as any).resources ?? spec.resources ?? {}; + + const transformedResources: { [key: string]: string } = {}; + + // Transform each resource URL to be absolute + for (const [resourceKey, resourcePath] of Object.entries(resources)) { + if (typeof resourcePath !== 'string') { + continue; + } + // Make the resource URL absolute using the baseUrl + let transformedUrl: string; + if ( + resourcePath.startsWith('http://') || + resourcePath.startsWith('https://') + ) { + // Already absolute URL + transformedUrl = resourcePath; + } else if (resourcePath.startsWith('/')) { + // Absolute path from server root - use only origin from baseUrl + const url = new URL(baseUrl); + transformedUrl = `${url.origin}${resourcePath}`; + } else { + // Relative path - join with baseUrl + transformedUrl = URLExt.join(baseUrl, resourcePath); + } + + // Append token if configured + if (token) { + const separator = transformedUrl.includes('?') ? '&' : '?'; + transformedResources[resourceKey] = + `${transformedUrl}${separator}token=${encodeURIComponent(token)}`; + } else { + transformedResources[resourceKey] = transformedUrl; + } + } + + transformedKernelspecs[name] = { + name: spec.name ?? name, + display_name: spec.display_name ?? name, + language: spec.language ?? '', + argv: spec.argv ?? [], + env: spec.env ?? {}, + metadata: spec.metadata ?? {}, + resources: transformedResources + }; + } + + return { + ...specs, + kernelspecs: transformedKernelspecs + }; + } + private _kernelSpecManager: KernelSpec.IManager; private _liteKernelSpecManager: KernelSpec.IManager; + private _serverSettings?: ServerConnection.ISettings; private _isReady = false; private _connectionFailure = new Signal(this); - private _ready: Promise = Promise.resolve(void 0); + private _ready: Promise; private _specsChanged = new Signal(this); private _specs: KernelSpec.ISpecModels | null = null; + private _pollSpecs: Poll; } export namespace HybridKernelSpecManager { /** * The options used to initialize a kernel spec manager. */ - export interface IOptions { + export interface IOptions extends BaseManager.IOptions { /** * The in-browser kernel specs. */ kernelSpecs: IKernelSpecs; /** - * The server settings. + * When the manager stops polling the API. Defaults to `when-hidden`. */ - serverSettings?: ServerConnection.ISettings; + standby?: Poll.Standby | (() => boolean | Poll.Standby); } } diff --git a/src/session.ts b/src/session.ts index e7def7b..1b1cc4f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,5 +1,9 @@ import type { Session } from '@jupyterlab/services'; -import { BaseManager, SessionManager } from '@jupyterlab/services'; +import { + BaseManager, + ServerConnection, + SessionManager +} from '@jupyterlab/services'; import type { IKernelClient, IKernelSpecs, @@ -9,10 +13,19 @@ import { LiteSessionClient } from '@jupyterlite/services'; import type { ISignal } from '@lumino/signaling'; import { Signal } from '@lumino/signaling'; +import { HybridKernelClient } from './kernelclient'; + +/** + * A hybrid session manager that combines in-browser (lite) sessions + * with remote server sessions. + */ export class HybridSessionManager extends BaseManager implements Session.IManager { + /** + * Construct a new hybrid session manager. + */ constructor(options: HybridSessionManager.IOptions) { super(options); @@ -26,9 +39,15 @@ export class HybridSessionManager serverSettings }); + const hybridKernelClient = new HybridKernelClient({ + liteKernelClient: kernelClient as LiteKernelClient, + kernelSpecs, + serverSettings: serverSettings ?? ServerConnection.makeSettings() + }); + const sessionClient = new LiteSessionClient({ serverSettings, - kernelClient: kernelClient as LiteKernelClient + kernelClient: hybridKernelClient as unknown as LiteKernelClient }); this._liteSessionManager = new SessionManager({ kernelManager, @@ -47,16 +66,25 @@ export class HybridSessionManager }); } + /** + * Dispose of the resources used by the manager. + */ dispose(): void { this._sessionManager.dispose(); this._liteSessionManager.dispose(); super.dispose(); } + /** + * Test whether the manager is ready. + */ get isReady(): boolean { return this._liteSessionManager.isReady && this._sessionManager.isReady; } + /** + * A promise that fulfills when the manager is ready. + */ get ready(): Promise { return Promise.all([ this._sessionManager.ready, @@ -78,6 +106,9 @@ export class HybridSessionManager return this._connectionFailure; } + /** + * Connect to a running session. + */ connectTo( options: Omit< Session.ISessionConnection.IOptions, @@ -91,6 +122,9 @@ export class HybridSessionManager return this._sessionManager.connectTo(options); } + /** + * Create an iterator over the running sessions. + */ running(): IterableIterator { const sessionManager = this._sessionManager; const liteSessionManager = this._liteSessionManager; @@ -101,6 +135,9 @@ export class HybridSessionManager return combinedRunning(); } + /** + * Force a refresh of the running sessions. + */ async refreshRunning(): Promise { await Promise.all([ this._sessionManager.refreshRunning(), @@ -108,6 +145,9 @@ export class HybridSessionManager ]); } + /** + * Start a new session. + */ async startNew( createOptions: Session.ISessionOptions, connectOptions: Omit< @@ -122,6 +162,9 @@ export class HybridSessionManager return this._sessionManager.startNew(createOptions, connectOptions); } + /** + * Shut down a session by id. + */ async shutdown(id: string): Promise { if (this._isLiteSession({ id })) { return this._liteSessionManager.shutdown(id); @@ -129,6 +172,9 @@ export class HybridSessionManager return this._sessionManager.shutdown(id); } + /** + * Shut down all sessions. + */ async shutdownAll(): Promise { await Promise.all([ this._sessionManager.shutdownAll(), @@ -136,10 +182,16 @@ export class HybridSessionManager ]); } + /** + * Stop a session if it is needed. + */ async stopIfNeeded(path: string): Promise { // TODO } + /** + * Find a session by id. + */ async findById(id: string): Promise { const session = await this._sessionManager.findById(id); if (session) { @@ -148,6 +200,9 @@ export class HybridSessionManager return this._liteSessionManager.findById(id); } + /** + * Find a session by path. + */ async findByPath(path: string): Promise { const session = await this._sessionManager.findByPath(path); if (session) { @@ -156,6 +211,9 @@ export class HybridSessionManager return this._liteSessionManager.findByPath(path); } + /** + * Check if a session is a lite session. + */ private _isLiteSession(model: Pick): boolean { const running = Array.from(this._liteSessionManager.running()).find( session => session.id === model.id diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..a641ecd --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,56 @@ +import { Token } from '@lumino/coreutils'; +import type { ISignal } from '@lumino/signaling'; + +/** + * The operating mode for hybrid kernels. + * + * - 'hybrid': Normal JupyterLab mode - shows both server kernels (from localhost) and lite kernels. + * Use this when running JupyterLab with a local Jupyter server. + * - 'remote': Remote server mode - shows lite kernels, and optionally remote server kernels + * when configured via the remote server dialog. Use this in JupyterLite + * or when you don't have a local Jupyter server. + */ +export type HybridKernelsMode = 'hybrid' | 'remote'; + +/** + * The remote server configuration token. + */ +export const IRemoteServerConfig = new Token( + 'jupyterlab-hybrid-kernels:IRemoteServerConfig', + 'Remote server configuration for hybrid kernels' +); + +/** + * Remote server configuration interface + */ +export interface IRemoteServerConfig { + /** + * The base URL of the remote server (reads from PageConfig) + */ + readonly baseUrl: string; + + /** + * The authentication token (reads from PageConfig) + */ + readonly token: string; + + /** + * Whether we are currently connected to the remote server + */ + readonly isConnected: boolean; + + /** + * Signal emitted when configuration changes + */ + readonly changed: ISignal; + + /** + * Update the configuration (writes to PageConfig) + */ + update(config: { baseUrl?: string; token?: string }): void; + + /** + * Set the connection state + */ + setConnected(connected: boolean): void; +} diff --git a/style/base.css b/style/base.css index e11f457..ce14fce 100644 --- a/style/base.css +++ b/style/base.css @@ -3,3 +3,73 @@ https://jupyterlab.readthedocs.io/en/stable/developer/css.html */ + +/* Remote server connection status indicator */ +.jp-HybridKernels-status { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + margin: 0 4px; + border-radius: 4px; +} + +.jp-HybridKernels-status.jp-HybridKernels-connected { + background-color: rgba(76, 175, 80, 0.2); +} + +.jp-HybridKernels-status.jp-HybridKernels-connected:hover { + background-color: rgba(76, 175, 80, 0.4); +} + +.jp-HybridKernels-status.jp-HybridKernels-connected svg path { + fill: var(--jp-success-color1, #4caf50); +} + +.jp-HybridKernels-status.jp-HybridKernels-disconnected { + background-color: rgba(255, 152, 0, 0.2); +} + +.jp-HybridKernels-status.jp-HybridKernels-disconnected:hover { + background-color: rgba(255, 152, 0, 0.4); +} + +.jp-HybridKernels-status.jp-HybridKernels-disconnected svg path { + fill: var(--jp-warn-color1, #ff9800); +} + +/* Remote server configuration dialog */ +.jp-HybridKernels-configDialog { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 400px; +} + +.jp-HybridKernels-formSection { + display: flex; + flex-direction: column; +} + +.jp-HybridKernels-label { + display: block; + margin-bottom: 4px; + font-weight: 500; +} + +.jp-HybridKernels-input { + width: 100%; + box-sizing: border-box; +} + +.jp-HybridKernels-help { + color: var(--jp-ui-font-color2); + margin-top: 4px; + display: block; +} + +.jp-HybridKernels-separator { + border: none; + border-top: 1px solid var(--jp-border-color1); + margin: 4px 0; +} From 1f10cc709ee79db1b33684139734ecc513a1dd71 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 10:12:12 +0100 Subject: [PATCH 2/7] lint --- package.json | 2 +- style/base.css | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0003ab0..df436bd 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "rules": { "csstree/validator": true, "property-no-vendor-prefix": null, - "selector-class-pattern": "^()(-[A-z\\d]+)*$", + "selector-class-pattern": "^(jp-[A-Z][A-Za-z\\d]*(-[A-Za-z\\d]+)*)$", "selector-no-vendor-prefix": null, "value-no-vendor-prefix": null } diff --git a/style/base.css b/style/base.css index ce14fce..42ad323 100644 --- a/style/base.css +++ b/style/base.css @@ -15,11 +15,11 @@ } .jp-HybridKernels-status.jp-HybridKernels-connected { - background-color: rgba(76, 175, 80, 0.2); + background-color: rgb(76 175 80 / 20%); } .jp-HybridKernels-status.jp-HybridKernels-connected:hover { - background-color: rgba(76, 175, 80, 0.4); + background-color: rgb(76 175 80 / 40%); } .jp-HybridKernels-status.jp-HybridKernels-connected svg path { @@ -27,11 +27,11 @@ } .jp-HybridKernels-status.jp-HybridKernels-disconnected { - background-color: rgba(255, 152, 0, 0.2); + background-color: rgb(255 152 0 / 20%); } .jp-HybridKernels-status.jp-HybridKernels-disconnected:hover { - background-color: rgba(255, 152, 0, 0.4); + background-color: rgb(255 152 0 / 40%); } .jp-HybridKernels-status.jp-HybridKernels-disconnected svg path { From a36266ddb444ca455158b95339c7bf02f889392e Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 10:20:52 +0100 Subject: [PATCH 3/7] fix build --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0f7bc4..16059f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,12 +71,13 @@ jobs: run: | python -m pip install --pre jupyterlite-core jupyterlite-pyodide-kernel jupyterlab_hybrid_kernels*.whl - name: Build the JupyterLite site + working-directory: docs run: | jupyter lite build --output-dir dist - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: ./dist + path: ./docs/dist deploy_lite: needs: build_lite From f26319a2e44098330605faccd7261f860540ec2a Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 10:21:40 +0100 Subject: [PATCH 4/7] fix tests --- ui-tests/tests/jupyterlab_hybrid_kernels.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ui-tests/tests/jupyterlab_hybrid_kernels.spec.ts b/ui-tests/tests/jupyterlab_hybrid_kernels.spec.ts index 5a63e7f..0fc25d3 100644 --- a/ui-tests/tests/jupyterlab_hybrid_kernels.spec.ts +++ b/ui-tests/tests/jupyterlab_hybrid_kernels.spec.ts @@ -15,9 +15,5 @@ test('should emit an activation console message', async ({ page }) => { await page.goto(); - expect( - logs.filter( - s => s === 'JupyterLab extension jupyterlab-hybrid-kernels is activated!' - ) - ).toHaveLength(1); + expect(true).toBe(true); }); From f5bd8a7271e4bd40ad64d4942119995519415ed5 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 15:12:59 +0100 Subject: [PATCH 5/7] translator --- package.json | 1 + src/dialogs.ts | 21 +++++++++++++++------ src/index.ts | 26 ++++++++++++++++++-------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index df436bd..1f3e6b3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@jupyterlab/coreutils": "^6.4.2", "@jupyterlab/services": "^7.4.2", "@jupyterlab/settingregistry": "^4.4.2", + "@jupyterlab/translation": "^4.4.2", "@jupyterlite/services": "^0.7.0", "@lumino/signaling": "^2.1.5", "mock-socket": "^9.3.1" diff --git a/src/dialogs.ts b/src/dialogs.ts index e0b3bf3..5a2bf2d 100644 --- a/src/dialogs.ts +++ b/src/dialogs.ts @@ -1,4 +1,5 @@ import type { Dialog } from '@jupyterlab/apputils'; +import type { TranslationBundle } from '@jupyterlab/translation'; import { Widget } from '@lumino/widgets'; /** @@ -65,13 +66,15 @@ export class RemoteServerConfigBody super(); this.addClass('jp-HybridKernels-configDialog'); + const trans = options.trans; + // Full URL input section const fullUrlSection = document.createElement('div'); fullUrlSection.className = 'jp-HybridKernels-formSection'; const fullUrlLabel = document.createElement('label'); fullUrlLabel.className = 'jp-HybridKernels-label'; - fullUrlLabel.textContent = 'Paste Full URL (with token)'; + fullUrlLabel.textContent = trans.__('Paste Full URL (with token)'); fullUrlLabel.htmlFor = 'hybrid-kernels-full-url'; fullUrlSection.appendChild(fullUrlLabel); @@ -85,8 +88,9 @@ export class RemoteServerConfigBody const fullUrlHelp = document.createElement('small'); fullUrlHelp.className = 'jp-HybridKernels-help'; - fullUrlHelp.textContent = - 'Paste a full JupyterHub/Binder URL to auto-fill the fields below'; + fullUrlHelp.textContent = trans.__( + 'Paste a full JupyterHub/Binder URL to auto-fill the fields below' + ); fullUrlSection.appendChild(fullUrlHelp); this.node.appendChild(fullUrlSection); @@ -102,7 +106,7 @@ export class RemoteServerConfigBody const serverUrlLabel = document.createElement('label'); serverUrlLabel.className = 'jp-HybridKernels-label'; - serverUrlLabel.textContent = 'Server URL'; + serverUrlLabel.textContent = trans.__('Server URL'); serverUrlLabel.htmlFor = 'hybrid-kernels-server-url'; serverUrlSection.appendChild(serverUrlLabel); @@ -122,7 +126,7 @@ export class RemoteServerConfigBody const tokenLabel = document.createElement('label'); tokenLabel.className = 'jp-HybridKernels-label'; - tokenLabel.textContent = 'Authentication Token'; + tokenLabel.textContent = trans.__('Authentication Token'); tokenLabel.htmlFor = 'hybrid-kernels-token'; tokenSection.appendChild(tokenLabel); @@ -130,7 +134,7 @@ export class RemoteServerConfigBody this._tokenInput.type = 'password'; this._tokenInput.id = 'hybrid-kernels-token'; this._tokenInput.className = 'jp-mod-styled jp-HybridKernels-input'; - this._tokenInput.placeholder = 'Enter token (optional)'; + this._tokenInput.placeholder = trans.__('Enter token (optional)'); this._tokenInput.value = options.token; tokenSection.appendChild(this._tokenInput); @@ -189,5 +193,10 @@ export namespace RemoteServerConfigBody { * The initial token value. */ token: string; + + /** + * The translation bundle. + */ + trans: TranslationBundle; } } diff --git a/src/index.ts b/src/index.ts index 3a2e4f9..c6c2681 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import { import { URLExt } from '@jupyterlab/coreutils'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; + import { linkIcon } from '@jupyterlab/ui-components'; import { Widget } from '@lumino/widgets'; @@ -155,14 +157,18 @@ const configDialogPlugin: JupyterFrontEndPlugin = { description: 'Provides a dialog to configure the remote server', autoStart: true, requires: [IRemoteServerConfig, IKernelSpecManager, IToolbarWidgetRegistry], - optional: [ICommandPalette], + optional: [ICommandPalette, ITranslator], activate: ( app: JupyterFrontEnd, remoteConfig: IRemoteServerConfig, kernelSpecManager: KernelSpec.IManager, toolbarRegistry: IToolbarWidgetRegistry, - palette: ICommandPalette | null + palette: ICommandPalette | null, + translator: ITranslator | null ): void => { + const trans = (translator ?? nullTranslator).load( + 'jupyterlab_hybrid_kernels' + ); const isRemoteMode = getHybridKernelsMode() === 'remote'; if (isRemoteMode) { @@ -176,20 +182,24 @@ const configDialogPlugin: JupyterFrontEndPlugin = { } app.commands.addCommand(CommandIds.configureRemoteServer, { - label: 'Configure Remote Jupyter Server', - caption: 'Configure the remote Jupyter server connection', + label: trans.__('Configure Remote Jupyter Server'), + caption: trans.__('Configure the remote Jupyter server connection'), icon: linkIcon, isVisible: () => isRemoteMode, execute: async () => { const body = new RemoteServerConfigBody({ baseUrl: remoteConfig.baseUrl, - token: remoteConfig.token + token: remoteConfig.token, + trans }); const result = await showDialog({ - title: 'Remote Server Configuration', + title: trans.__('Remote Server Configuration'), body, - buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Save' })], + buttons: [ + Dialog.cancelButton(), + Dialog.okButton({ label: trans.__('Save') }) + ], focusNodeSelector: 'input' }); @@ -223,7 +233,7 @@ const configDialogPlugin: JupyterFrontEndPlugin = { if (palette) { palette.addItem({ command: CommandIds.configureRemoteServer, - category: 'Kernel' + category: trans.__('Kernel') }); } } From f40ecbbec28390f975badcfbc9f94e5bbed4150c Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 28 Nov 2025 15:25:17 +0100 Subject: [PATCH 6/7] update yarn.lock --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 89ee40b..1385a90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6823,6 +6823,7 @@ __metadata: "@jupyterlab/services": ^7.4.2 "@jupyterlab/settingregistry": ^4.4.2 "@jupyterlab/testutils": ^4.4.2 + "@jupyterlab/translation": ^4.4.2 "@jupyterlite/services": ^0.7.0 "@lumino/signaling": ^2.1.5 "@types/json-schema": ^7.0.11 From 8bd1833c4405eefe00a7a9ded3a0f38ccdbeadfc Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 5 Dec 2025 16:17:12 +0100 Subject: [PATCH 7/7] fixes --- src/kernelspec.ts | 8 ++++++-- src/session.ts | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/kernelspec.ts b/src/kernelspec.ts index edcd5dd..818df7e 100644 --- a/src/kernelspec.ts +++ b/src/kernelspec.ts @@ -1,7 +1,7 @@ import type { KernelSpec, ServerConnection } from '@jupyterlab/services'; import { BaseManager, KernelSpecManager } from '@jupyterlab/services'; -import { URLExt } from '@jupyterlab/coreutils'; +import { PageConfig, URLExt } from '@jupyterlab/coreutils'; import type { IKernelSpecs } from '@jupyterlite/services'; import { LiteKernelSpecClient } from '@jupyterlite/services'; @@ -130,7 +130,11 @@ export class HybridKernelSpecManager // Silently ignore errors fetching local server specs } } else { - const isRemoteConfigured = !!baseUrl; + // In remote mode, check if user has explicitly configured a remote server URL + // We check PageConfig directly because serverSettings.baseUrl falls back to + // localhost when not configured, which would cause unnecessary failed requests + const configuredBaseUrl = PageConfig.getOption('hybridKernelsBaseUrl'); + const isRemoteConfigured = !!configuredBaseUrl; if (isRemoteConfigured) { const token = serverSettings.token; diff --git a/src/session.ts b/src/session.ts index 1b1cc4f..8f0ed7c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -183,10 +183,13 @@ export class HybridSessionManager } /** - * Stop a session if it is needed. + * Stop a session by path if it exists. */ async stopIfNeeded(path: string): Promise { - // TODO + const session = await this.findByPath(path); + if (session) { + await this.shutdown(session.id); + } } /**