From c5cd0aa2a48c12b28c6e8838107d57130de64bee Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:54:41 -0800 Subject: [PATCH 01/11] Add S3 file picker in wizard and clickable file links in job detail Add a "Browse S3" button to the job creation wizard that opens JupyterLab's built-in FileDialog rooted at lakehouse_minio/, converting selected paths to s3a:// URLs via a new bidirectional path mapping utility. In the job detail view, input files are now displayed in a new section and both input and output file paths are rendered as clickable links that open in JupyterLab's file browser when they map to a known lakehouse_minio/ virtual directory. --- berdl_task_browser/__init__.py | 6 + package.json | 3 + src/components/CTSBrowser.tsx | 15 +- src/components/JobDetail.tsx | 85 ++++++-- src/dialogs/JobWizardDialog.ts | 64 +++++- src/index.ts | 10 +- src/utils/s3PathMapping.ts | 116 +++++++++++ style/base.css | 7 + yarn.lock | 352 +++++++++++++++++++++++++++++++++ 9 files changed, 632 insertions(+), 26 deletions(-) create mode 100644 src/utils/s3PathMapping.ts diff --git a/berdl_task_browser/__init__.py b/berdl_task_browser/__init__.py index 76c0303..89aabfe 100644 --- a/berdl_task_browser/__init__.py +++ b/berdl_task_browser/__init__.py @@ -47,4 +47,10 @@ def _load_jupyter_server_extension(server_app): if os.environ.get("CTS_MOCK_MODE", "").lower() in ("true", "1", "yes"): page_config["ctsMockMode"] = "true" + if hub_user := os.environ.get("NB_USER"): + page_config["hubUser"] = hub_user + + if bucket := os.environ.get("CDM_DEFAULT_BUCKET"): + page_config["cdmDefaultBucket"] = bucket + server_app.log.info("Registered berdl_task_browser server extension") diff --git a/package.json b/package.json index 7891e9c..fac6d3f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,9 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@jupyterlab/application": "^4.0.0", "@jupyterlab/apputils": "^4.0.0", + "@jupyterlab/coreutils": "^6.0.0", + "@jupyterlab/docmanager": "^4.0.0", + "@jupyterlab/filebrowser": "^4.0.0", "@jupyterlab/services": "^7.0.0", "@jupyterlab/statedb": "^4.0.0", "@jupyterlab/ui-components": "^4.0.0", diff --git a/src/components/CTSBrowser.tsx b/src/components/CTSBrowser.tsx index 8af2539..3329fbc 100644 --- a/src/components/CTSBrowser.tsx +++ b/src/components/CTSBrowser.tsx @@ -1,5 +1,7 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { JupyterFrontEnd } from '@jupyterlab/application'; import { INotebookTracker } from '@jupyterlab/notebook'; +import { IDocumentManager } from '@jupyterlab/docmanager'; import { Box, Typography, IconButton, Tooltip } from '@mui/material'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'; @@ -13,10 +15,16 @@ import { registerSelectJobCallback } from '../index'; import { getToken } from '../auth/token'; export interface ICTSBrowserProps { + app: JupyterFrontEnd; notebookTracker: INotebookTracker | null; + documentManager: IDocumentManager | null; } -export const CTSBrowser: React.FC = ({ notebookTracker }) => { +export const CTSBrowser: React.FC = ({ + app, + notebookTracker, + documentManager +}) => { const [filters, setFilters] = useState({}); const [selectedJobId, setSelectedJobId] = useState(null); @@ -48,8 +56,8 @@ export const CTSBrowser: React.FC = ({ notebookTracker }) => { }, []); const handleOpenWizard = useCallback(() => { - showJobWizardDialog(notebookTracker); - }, [notebookTracker]); + showJobWizardDialog(notebookTracker, undefined, documentManager); + }, [notebookTracker, documentManager]); const statusSummary = useMemo(() => { const jobs = jobsQuery.data; @@ -145,6 +153,7 @@ export const CTSBrowser: React.FC = ({ notebookTracker }) => { isLoading={jobDetailQuery.isLoading} error={jobDetailQuery.error} onClose={handleCloseDetail} + app={app} /> )} diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 73b4184..3ce645a 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Box, Typography, @@ -9,6 +9,7 @@ import { Chip, Link } from '@mui/material'; +import { JupyterFrontEnd } from '@jupyterlab/application'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTimes, faBan } from '@fortawesome/free-solid-svg-icons'; import { @@ -21,12 +22,14 @@ import { StatusChip } from './StatusChip'; import { LogViewer, LogViewerEmpty } from './LogViewer'; import { useCancelJob, useJobExitCodes } from '../api/ctsApi'; import { MAX_DISPLAYED_OUTPUTS } from '../config'; +import { s3ToJlab } from '../utils/s3PathMapping'; interface IJobDetailProps { job: IJob | undefined; isLoading: boolean; error: Error | null; onClose: () => void; + app: JupyterFrontEnd; } const formatTimestamp = (isoString: string): string => { @@ -71,13 +74,40 @@ const StateTimeline: React.FC<{ transitions: ITransitionTime[] }> = ({ ); }; +const fileSx = { + fontSize: '0.6rem', + fontFamily: 'monospace', + wordBreak: 'break-all' +}; + +const FileLink: React.FC<{ + s3Path: string; + onClick: (path: string) => void; +}> = ({ s3Path, onClick }) => { + const jlabPath = s3ToJlab(s3Path); + if (jlabPath) { + return ( + onClick(jlabPath)} + sx={{ ...fileSx, textAlign: 'left', cursor: 'pointer' }} + > + {s3Path} + + ); + } + return {s3Path}; +}; + export const JobDetail: React.FC = ({ job, isLoading, error, - onClose + onClose, + app }) => { const [showAllOutputs, setShowAllOutputs] = useState(false); + const [showAllInputs, setShowAllInputs] = useState(false); const cancelMutation = useCancelJob(); const exitCodesQuery = useJobExitCodes( job?.id || null, @@ -90,6 +120,13 @@ export const JobDetail: React.FC = ({ } }; + const openFile = useCallback( + (path: string) => { + app.commands.execute('filebrowser:open-path', { path }); + }, + [app] + ); + if (isLoading && !job) { return ( @@ -343,6 +380,40 @@ export const JobDetail: React.FC = ({ )} + {/* Input Files */} + {job.job_input?.input_files && job.job_input.input_files.length > 0 && ( + + + Input Files ({job.job_input.input_files.length}) + + + {(showAllInputs + ? job.job_input.input_files + : job.job_input.input_files.slice(0, MAX_DISPLAYED_OUTPUTS) + ).map((file, index) => ( + + + + ))} + {job.job_input.input_files.length > MAX_DISPLAYED_OUTPUTS && ( + setShowAllInputs(!showAllInputs)} + sx={{ + fontSize: '0.55rem', + color: 'primary.main', + cursor: 'pointer' + }} + > + {showAllInputs + ? 'Show less' + : `Show all ${job.job_input.input_files.length} inputs`} + + )} + + + )} + {/* Outputs */} {job.outputs && job.outputs.length > 0 && ( @@ -353,15 +424,7 @@ export const JobDetail: React.FC = ({ : job.outputs.slice(0, MAX_DISPLAYED_OUTPUTS) ).map((output, index) => ( - - {output.file} - + ))} {job.outputs.length > MAX_DISPLAYED_OUTPUTS && ( diff --git a/src/dialogs/JobWizardDialog.ts b/src/dialogs/JobWizardDialog.ts index b2ea4a7..7f15b91 100644 --- a/src/dialogs/JobWizardDialog.ts +++ b/src/dialogs/JobWizardDialog.ts @@ -1,10 +1,13 @@ import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { FileDialog } from '@jupyterlab/filebrowser'; +import { IDocumentManager } from '@jupyterlab/docmanager'; import { Widget } from '@lumino/widgets'; import { INotebookTracker } from '@jupyterlab/notebook'; import { insertCodeCell, generateJobCreationCode } from '../utils/notebookUtils'; +import { jlabToS3 } from '../utils/s3PathMapping'; export type MemoryUnit = 'GB' | 'MB' | 'Gi' | 'Mi'; @@ -69,9 +72,14 @@ export class JobWizardBody private _argsContainer!: HTMLDivElement; private _errorDisplay!: HTMLDivElement; private _args: string[] = []; + private _documentManager: IDocumentManager | null; - constructor(initialData?: IWizardFormData) { + constructor( + initialData?: IWizardFormData, + documentManager?: IDocumentManager | null + ) { super(); + this._documentManager = documentManager ?? null; this.addClass('jp-JobWizard'); this._buildForm(); if (initialData) { @@ -122,13 +130,28 @@ export class JobWizardBody this._inputFilesContainer.className = 'jp-JobWizard-args'; inputFilesSection.appendChild(this._inputFilesContainer); + const buttonRow = document.createElement('div'); + buttonRow.className = 'jp-JobWizard-browseRow'; + const addInputFileButton = document.createElement('button'); addInputFileButton.type = 'button'; addInputFileButton.className = 'jp-mod-styled jp-mod-accept jp-JobWizard-addArg'; addInputFileButton.textContent = '+ Add input file'; addInputFileButton.addEventListener('click', () => this._addInputFile()); - inputFilesSection.appendChild(addInputFileButton); + buttonRow.appendChild(addInputFileButton); + + if (this._documentManager) { + const browseButton = document.createElement('button'); + browseButton.type = 'button'; + browseButton.className = + 'jp-mod-styled jp-mod-accept jp-JobWizard-addArg'; + browseButton.textContent = 'Browse S3'; + browseButton.addEventListener('click', () => this._browseS3()); + buttonRow.appendChild(browseButton); + } + + inputFilesSection.appendChild(buttonRow); this.node.appendChild(inputFilesSection); @@ -321,6 +344,25 @@ export class JobWizardBody }); } + private async _browseS3(): Promise { + if (!this._documentManager) { + return; + } + const result = await FileDialog.getOpenFiles({ + manager: this._documentManager, + defaultPath: 'lakehouse_minio/', + title: 'Select Input Files' + }); + if (result.button.accept && result.value) { + for (const model of result.value) { + const s3Url = jlabToS3(model.path); + if (s3Url) { + this._addInputFile(s3Url); + } + } + } + } + private _addArg(value = ''): void { const index = this._args.length; this._args.push(value); @@ -448,9 +490,13 @@ export class JobWizardBody export async function showJobWizardDialog( notebookTracker: INotebookTracker | null, - initialData?: IWizardFormData + initialData?: IWizardFormData, + documentManager?: IDocumentManager | null ): Promise { - const body = new JobWizardBody(initialData); + const reopen = (data: IWizardFormData) => + showJobWizardDialog(notebookTracker, data, documentManager); + + const body = new JobWizardBody(initialData, documentManager); const result = await showDialog({ title: 'Create CTS Job', @@ -465,7 +511,7 @@ export async function showJobWizardDialog( if (result.button.label === 'Source') { if (!body.validate()) { - return showJobWizardDialog(notebookTracker, body.getValue()); + return reopen(body.getValue()); } const formData = body.getValue(); @@ -502,12 +548,12 @@ export async function showJobWizardDialog( buttons: [Dialog.okButton({ label: 'Close' })] }); - return showJobWizardDialog(notebookTracker, formData); + return reopen(formData); } if (result.button.accept) { if (!body.validate()) { - return showJobWizardDialog(notebookTracker, body.getValue()); + return reopen(body.getValue()); } const formData = result.value; @@ -521,7 +567,7 @@ export async function showJobWizardDialog( body: 'No active notebook found. Please open a notebook first.', buttons: [Dialog.okButton()] }); - return showJobWizardDialog(notebookTracker, formData); + return reopen(formData); } const code = generateJobCreationCode({ @@ -541,7 +587,7 @@ export async function showJobWizardDialog( body: 'Failed to insert code into notebook', buttons: [Dialog.okButton()] }); - return showJobWizardDialog(notebookTracker, formData); + return reopen(formData); } return true; diff --git a/src/index.ts b/src/index.ts index a1ba3f9..72e9dfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { } from '@jupyterlab/application'; import { IStateDB } from '@jupyterlab/statedb'; import { INotebookTracker } from '@jupyterlab/notebook'; +import { IDocumentManager } from '@jupyterlab/docmanager'; import { Panel } from '@lumino/widgets'; import { LabIcon } from '@jupyterlab/ui-components'; @@ -146,12 +147,13 @@ const plugin: JupyterFrontEndPlugin = { description: 'A JupyterLab extension for browsing CTS data', autoStart: true, requires: [ILayoutRestorer, IStateDB], - optional: [INotebookTracker], + optional: [INotebookTracker, IDocumentManager], activate: ( app: JupyterFrontEnd, restorer: ILayoutRestorer, stateDB: IStateDB, - notebookTracker: INotebookTracker | null + notebookTracker: INotebookTracker | null, + documentManager: IDocumentManager | null ) => { registerCTSNamespace(app); @@ -174,7 +176,9 @@ const plugin: JupyterFrontEndPlugin = { ErrorBoundary, null, React.createElement(CTSBrowser, { - notebookTracker + app, + notebookTracker, + documentManager }) ) ) diff --git a/src/utils/s3PathMapping.ts b/src/utils/s3PathMapping.ts new file mode 100644 index 0000000..a6d7a9d --- /dev/null +++ b/src/utils/s3PathMapping.ts @@ -0,0 +1,116 @@ +/** + * Bidirectional path mapping between JupyterLab lakehouse_minio/ virtual + * directories and s3a:// URLs. + * + * Mapping convention (see GroupedS3ContentsManager): + * my-files <-> users-general-warehouse/{user} + * my-sql <-> users-sql-warehouse/{user} + * {t}-files[-ro] <-> tenant-general-warehouse/{t} + * {t}-sql[-ro] <-> tenant-sql-warehouse/{t} + */ + +import { PageConfig } from '@jupyterlab/coreutils'; + +const LAKEHOUSE = 'lakehouse_minio'; +const DEFAULT_BUCKET = 'cdm-lake'; + +function user(): string { + return PageConfig.getOption('hubUser') || ''; +} + +function bucket(): string { + return PageConfig.getOption('cdmDefaultBucket') || DEFAULT_BUCKET; +} + +/** Split "lakehouse_minio/manager-name/rest/of/path" into [manager, rest]. */ +function splitJlabPath(path: string): { manager: string; rest: string } | null { + if (!path.startsWith(`${LAKEHOUSE}/`)) { + return null; + } + const rel = path.slice(LAKEHOUSE.length + 1); + const i = rel.indexOf('/'); + if (i === -1) { + return null; + } + return { manager: rel.slice(0, i), rest: rel.slice(i + 1) }; +} + +/** Parse s3a://bucket/warehouse/entity/rest into parts. */ +function splitS3Url(url: string): { + urlBucket: string; + warehouse: string; + entity: string; + rest: string; +} | null { + const m = url.match(/^s3a?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(.*)$/); + if (!m) { + return null; + } + return { urlBucket: m[1], warehouse: m[2], entity: m[3], rest: m[4] }; +} + +/** + * Convert a JupyterLab lakehouse_minio/ path to an s3a:// URL. + * Returns null if the path is outside lakehouse_minio/ or unrecognised. + */ +export function jlabToS3(jlabPath: string): string | null { + const parts = splitJlabPath(jlabPath); + if (!parts) { + return null; + } + const { manager, rest } = parts; + const b = bucket(); + + if (manager === 'my-files') { + return `s3a://${b}/users-general-warehouse/${user()}/${rest}`; + } + if (manager === 'my-sql') { + return `s3a://${b}/users-sql-warehouse/${user()}/${rest}`; + } + + // Tenant: strip -files, -sql, -files-ro, -sql-ro suffix to get tenant name. + // Check longer suffixes first so -files-ro is matched before -files. + const suffixToWarehouse: [string, string][] = [ + ['-files-ro', 'tenant-general-warehouse'], + ['-sql-ro', 'tenant-sql-warehouse'], + ['-files', 'tenant-general-warehouse'], + ['-sql', 'tenant-sql-warehouse'] + ]; + for (const [suffix, warehouse] of suffixToWarehouse) { + if (manager.endsWith(suffix) && manager.length > suffix.length) { + const tenant = manager.slice(0, -suffix.length); + return `s3a://${b}/${warehouse}/${tenant}/${rest}`; + } + } + + return null; +} + +/** + * Convert an s3a:// (or s3://) URL to a JupyterLab lakehouse_minio/ path. + * Returns null if the URL doesn't map to any known virtual directory. + * + * For tenant paths, the RW manager name (e.g. "foo-files") is returned since + * we can't know from the URL alone whether the user has RO or RW access. + */ +export function s3ToJlab(s3Url: string): string | null { + const parts = splitS3Url(s3Url); + if (!parts || parts.urlBucket !== bucket()) { + return null; + } + const { warehouse, entity, rest } = parts; + const u = user(); + + switch (warehouse) { + case 'users-general-warehouse': + return entity === u ? `${LAKEHOUSE}/my-files/${rest}` : null; + case 'users-sql-warehouse': + return entity === u ? `${LAKEHOUSE}/my-sql/${rest}` : null; + case 'tenant-general-warehouse': + return `${LAKEHOUSE}/${entity}-files/${rest}`; + case 'tenant-sql-warehouse': + return `${LAKEHOUSE}/${entity}-sql/${rest}`; + default: + return null; + } +} diff --git a/style/base.css b/style/base.css index 752e926..42f9b88 100644 --- a/style/base.css +++ b/style/base.css @@ -204,3 +204,10 @@ border-color: var(--jp-success-color1); color: var(--jp-success-color1); } + +/* Button row for input file actions */ +.jp-JobWizard-browseRow { + display: flex; + gap: 8px; + align-items: center; +} diff --git a/yarn.lock b/yarn.lock index 557b68f..18b870d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1596,6 +1596,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/state@npm:^6.5.4": + version: 6.5.4 + resolution: "@codemirror/state@npm:6.5.4" + dependencies: + "@marijn/find-cluster-break": ^1.0.0 + checksum: f5fec77bbfd10efc157fc93cf725fb55e4e7d2cf4919bb9e2e43ed9d86aa0f0ac423c2625da99710321e6073bce5f391f2d565db137ef2597dce7d038cfcc2ba + languageName: node + linkType: hard + "@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0, @codemirror/view@npm:^6.38.1": version: 6.39.4 resolution: "@codemirror/view@npm:6.39.4" @@ -2423,6 +2432,35 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/apputils@npm:^4.6.3": + version: 4.6.3 + resolution: "@jupyterlab/apputils@npm:4.6.3" + dependencies: + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/observables": ^5.5.3 + "@jupyterlab/rendermime-interfaces": ^3.13.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/settingregistry": ^4.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@jupyterlab/statusbar": ^4.5.3 + "@jupyterlab/translation": ^4.5.3 + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/domutils": ^2.0.4 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + "@lumino/widgets": ^2.7.3 + "@types/react": ^18.0.26 + react: ^18.2.0 + sanitize-html: ~2.12.1 + checksum: 6b175e3912f95d9a3fa13764df09633443c98ecd69e22b8fdf806cb13569437c1e24d305e3e25206b64132feed655843550b2d8ccdceb74e8809fbb64898cc75 + languageName: node + linkType: hard + "@jupyterlab/attachments@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/attachments@npm:4.5.1" @@ -2538,6 +2576,30 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/codeeditor@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/codeeditor@npm:4.5.3" + dependencies: + "@codemirror/state": ^6.5.4 + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.6.3 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/nbformat": ^4.5.3 + "@jupyterlab/observables": ^5.5.3 + "@jupyterlab/statusbar": ^4.5.3 + "@jupyterlab/translation": ^4.5.3 + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/dragdrop": ^2.1.7 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.3 + react: ^18.2.0 + checksum: 77e8f88bc6f46463dabd63b0ad6adb80a4b295d8564a9cdef40be6bb3b047c6a2d5f833755031adb7d630d4740a94af9d31f3b5354aa916c709eec229ca54271 + languageName: node + linkType: hard + "@jupyterlab/codemirror@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/codemirror@npm:4.5.1" @@ -2580,6 +2642,20 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.5.3": + version: 6.5.3 + resolution: "@jupyterlab/coreutils@npm:6.5.3" + dependencies: + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/signaling": ^2.1.5 + minimist: ~1.2.0 + path-browserify: ^1.0.0 + url-parse: ~1.5.4 + checksum: 9859e9c4a00d5b65b71099afeccb92f590ae1c4943e4537f434f874dac984ef854319d6ba06c42640e54e53bdda4a8c6d23ba91152c2baa6d507dce17dd242cb + languageName: node + linkType: hard + "@jupyterlab/coreutils@npm:^6.5.1": version: 6.5.1 resolution: "@jupyterlab/coreutils@npm:6.5.1" @@ -2594,6 +2670,32 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/docmanager@npm:^4.0.0, @jupyterlab/docmanager@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/docmanager@npm:4.5.3" + dependencies: + "@jupyterlab/apputils": ^4.6.3 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/docregistry": ^4.5.3 + "@jupyterlab/rendermime": ^4.5.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@jupyterlab/statusbar": ^4.5.3 + "@jupyterlab/translation": ^4.5.3 + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.3 + react: ^18.2.0 + checksum: 48e34f35a21aba043a0a4556b9524348b57e5d291a7772bcde2a50093ebd7e4fcbd987089f47aa9a2e850ae6076523043f4a87163389cec6cba1b22a7414432a + languageName: node + linkType: hard + "@jupyterlab/docmanager@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/docmanager@npm:4.5.1" @@ -2646,6 +2748,32 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/docregistry@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/docregistry@npm:4.5.3" + dependencies: + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.6.3 + "@jupyterlab/codeeditor": ^4.5.3 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/observables": ^5.5.3 + "@jupyterlab/rendermime": ^4.5.3 + "@jupyterlab/rendermime-interfaces": ^3.13.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/translation": ^4.5.3 + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.3 + react: ^18.2.0 + checksum: 13d1060b3ebcce8402303749cb0406e875faff796210f6311aaa2e1ac9019741e1b10c039d4d8351a0560e9625f7a5089e5189285d1b77095e74a6556a57c42d + languageName: node + linkType: hard + "@jupyterlab/documentsearch@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/documentsearch@npm:4.5.1" @@ -2665,6 +2793,35 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/filebrowser@npm:^4.0.0": + version: 4.5.3 + resolution: "@jupyterlab/filebrowser@npm:4.5.3" + dependencies: + "@jupyterlab/apputils": ^4.6.3 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/docmanager": ^4.5.3 + "@jupyterlab/docregistry": ^4.5.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@jupyterlab/statusbar": ^4.5.3 + "@jupyterlab/translation": ^4.5.3 + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/domutils": ^2.0.4 + "@lumino/dragdrop": ^2.1.7 + "@lumino/messaging": ^2.0.4 + "@lumino/polling": ^2.1.5 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + "@lumino/widgets": ^2.7.3 + jest-environment-jsdom: ^29.3.0 + react: ^18.2.0 + checksum: 7e5505b20ec9b6bd7fbb63b0b333a32c47c0d2f053e82101b429774e2c14167d6c176e2c604cab526969826bdc5c5d6282d6d9929b78a2bfeca9fc7eae087ee0 + languageName: node + linkType: hard + "@jupyterlab/filebrowser@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/filebrowser@npm:4.5.1" @@ -2758,6 +2915,15 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/nbformat@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/nbformat@npm:4.5.3" + dependencies: + "@lumino/coreutils": ^2.2.2 + checksum: 315d259e0e465e98631fc396e0468583b6979b8e246d5fc19995778f56fe3644588e68e7c8738b27f16dd1342cfdf3410563daeb7f909cec0b058f2116567475 + languageName: node + linkType: hard + "@jupyterlab/notebook@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/notebook@npm:4.5.1" @@ -2810,6 +2976,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/observables@npm:^5.5.3": + version: 5.5.3 + resolution: "@jupyterlab/observables@npm:5.5.3" + dependencies: + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + checksum: 49358b2378397525395fe4e93b45336e2dfd1a605f48be5377bc35eb5056f8239c3587fef17f6e9664385837c4ee89e57312e7beb646eebc366e894ea7347003 + languageName: node + linkType: hard + "@jupyterlab/outputarea@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/outputarea@npm:4.5.1" @@ -2842,6 +3021,16 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/rendermime-interfaces@npm:^3.13.3": + version: 3.13.3 + resolution: "@jupyterlab/rendermime-interfaces@npm:3.13.3" + dependencies: + "@lumino/coreutils": ^1.11.0 || ^2.2.2 + "@lumino/widgets": ^1.37.2 || ^2.7.3 + checksum: dc8ebd5a8226881df85f41d9c1c5524e0ccd8101ef860d50b013dd4f3f075152f33b300dd538800ca930189a60b80cc9c24a0dace9831c2fc6eef6eda008c77e + languageName: node + linkType: hard + "@jupyterlab/rendermime@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/rendermime@npm:4.5.1" @@ -2862,6 +3051,26 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/rendermime@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/rendermime@npm:4.5.3" + dependencies: + "@jupyterlab/apputils": ^4.6.3 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/nbformat": ^4.5.3 + "@jupyterlab/observables": ^5.5.3 + "@jupyterlab/rendermime-interfaces": ^3.13.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/translation": ^4.5.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.3 + lodash.escape: ^4.0.1 + checksum: e7bef1ddcc305f4405e0629f40c41454fb6662d1aaa9c2b311a83baf747d99c0e904c58142a17252f6e69c78fd81cfa7c5f743bd139ff6ca6992571cf3fea105 + languageName: node + linkType: hard + "@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.5.1": version: 7.5.1 resolution: "@jupyterlab/services@npm:7.5.1" @@ -2881,6 +3090,25 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/services@npm:^7.5.3": + version: 7.5.3 + resolution: "@jupyterlab/services@npm:7.5.3" + dependencies: + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/nbformat": ^4.5.3 + "@jupyterlab/settingregistry": ^4.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + ws: ^8.11.0 + checksum: 7ab0c9419f6768c129482c21141d9d970e63fdc7e86f35b0c980197666a54397e9e3549b56986bf35c8914e9b59b6b3ad81a8fcd91ecc3ba1a198a633b090d4e + languageName: node + linkType: hard + "@jupyterlab/settingregistry@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/settingregistry@npm:4.5.1" @@ -2900,6 +3128,25 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/settingregistry@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/settingregistry@npm:4.5.3" + dependencies: + "@jupyterlab/nbformat": ^4.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/signaling": ^2.1.5 + "@rjsf/utils": ^5.13.4 + ajv: ^8.12.0 + json5: ^2.2.3 + peerDependencies: + react: ">=16" + checksum: 8f6dd758de6e20432a90ca53bcad636685dc3930f73ed3069706b6bd676e050cd8453008dc47c53fbcd3b74bfc2e07adbed5ef8ca4649737c57e898dbd422af6 + languageName: node + linkType: hard + "@jupyterlab/statedb@npm:^4.0.0, @jupyterlab/statedb@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/statedb@npm:4.5.1" @@ -2913,6 +3160,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/statedb@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/statedb@npm:4.5.3" + dependencies: + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + checksum: ebcd0c533c39b4a8bea07e1271e2c1dce1c5867b9e37b662554707848c648a0b839e50629af8ba11192928ad5c19f8931278927bed1853d1c03c04a925653747 + languageName: node + linkType: hard + "@jupyterlab/statusbar@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/statusbar@npm:4.5.1" @@ -2929,6 +3189,22 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/statusbar@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/statusbar@npm:4.5.3" + dependencies: + "@jupyterlab/ui-components": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.3 + react: ^18.2.0 + checksum: e9269667e4d2ed3b8d836dd992ea37b5fb583b0993601bb800952d543ca89cff81ddcca850a4e5e2d46237681caa58ca9edfe5a106f34a7e23a836a217d1c404 + languageName: node + linkType: hard + "@jupyterlab/testing@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/testing@npm:4.5.1" @@ -3001,6 +3277,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/translation@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/translation@npm:4.5.3" + dependencies: + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/rendermime-interfaces": ^3.13.3 + "@jupyterlab/services": ^7.5.3 + "@jupyterlab/statedb": ^4.5.3 + "@lumino/coreutils": ^2.2.2 + checksum: b1f50361ee56b28b0500fc1deffb48cd75d1ad75f9022cb873f100e5fa20c863ce96c0d2c2ea49658f533789cd2dd6283e0d03e9b455bb2f8641b0fb9f603180 + languageName: node + linkType: hard + "@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.5.1": version: 4.5.1 resolution: "@jupyterlab/ui-components@npm:4.5.1" @@ -3032,6 +3321,37 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/ui-components@npm:^4.5.3": + version: 4.5.3 + resolution: "@jupyterlab/ui-components@npm:4.5.3" + dependencies: + "@jupyter/react-components": ^0.16.6 + "@jupyter/web-components": ^0.16.6 + "@jupyterlab/coreutils": ^6.5.3 + "@jupyterlab/observables": ^5.5.3 + "@jupyterlab/rendermime-interfaces": ^3.13.3 + "@jupyterlab/translation": ^4.5.3 + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + "@lumino/widgets": ^2.7.3 + "@rjsf/core": ^5.13.4 + "@rjsf/utils": ^5.13.4 + react: ^18.2.0 + react-dom: ^18.2.0 + typestyle: ^2.0.4 + peerDependencies: + react: ^18.2.0 + checksum: a068b9a1c6a0e5541b3a5d5190e9e13c81065fe0a826bbbe2789a64bcb141119f0dc6d9e66ae77316f3155a7771783628f3bbbbb17803e0127aed69452c2b4c9 + languageName: node + linkType: hard + "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1, @lezer/common@npm:^1.3.0": version: 1.4.0 resolution: "@lezer/common@npm:1.4.0" @@ -3266,6 +3586,16 @@ __metadata: languageName: node linkType: hard +"@lumino/dragdrop@npm:^2.1.8": + version: 2.1.8 + resolution: "@lumino/dragdrop@npm:2.1.8" + dependencies: + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + checksum: 2e772456ce911c6c4941df4213eebeb64265f7ee36f83332ac1abb01bbc1b88b84978616dbc58cf7e8cb053db2018090ad68221bc343acaf1b6dcbbe355e25ab + languageName: node + linkType: hard + "@lumino/keyboard@npm:^2.0.4": version: 2.0.4 resolution: "@lumino/keyboard@npm:2.0.4" @@ -3339,6 +3669,25 @@ __metadata: languageName: node linkType: hard +"@lumino/widgets@npm:^1.37.2 || ^2.7.3, @lumino/widgets@npm:^2.7.3": + version: 2.7.5 + resolution: "@lumino/widgets@npm:2.7.5" + dependencies: + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/domutils": ^2.0.4 + "@lumino/dragdrop": ^2.1.8 + "@lumino/keyboard": ^2.0.4 + "@lumino/messaging": ^2.0.4 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + checksum: 0d5ee9a04bca0fe8f48f4f8b486128acc6c67586c5c65c75f7a8c78bfef931b9bdd49ab741883427e0f2375669a5c1821e90a662c81c63b6cc9e8f5c555e2f14 + languageName: node + linkType: hard + "@marijn/find-cluster-break@npm:^1.0.0": version: 1.0.2 resolution: "@marijn/find-cluster-break@npm:1.0.2" @@ -5050,6 +5399,9 @@ __metadata: "@jupyterlab/application": ^4.0.0 "@jupyterlab/apputils": ^4.0.0 "@jupyterlab/builder": ^4.0.0 + "@jupyterlab/coreutils": ^6.0.0 + "@jupyterlab/docmanager": ^4.0.0 + "@jupyterlab/filebrowser": ^4.0.0 "@jupyterlab/services": ^7.0.0 "@jupyterlab/statedb": ^4.0.0 "@jupyterlab/testutils": ^4.0.0 From 22dc909dfda6e599353c814651ad9d63f4768983 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:36:26 -0800 Subject: [PATCH 02/11] Add S3 file paths to mock data for file picker and link testing Mock jobs now include input_files with S3 URLs and outputs use full S3 paths, so file picker and clickable file links are exercisable in mock mode. Also defaults hubUser to 'testuser' when CTS_MOCK_MODE is enabled, ensuring s3ToJlab path mapping works without JupyterHub. --- README.md | 12 ++++---- berdl_task_browser/__init__.py | 1 + src/api/mockData.ts | 53 ++++++++++++++++++++++++---------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d56ac2f..45f88fd 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,13 @@ With the watch command running, every saved change will immediately be built loc #### Environment variables -| Variable | Description | -| ---------------------- | ---------------------------------------------- | -| `KBASE_AUTH_TOKEN` | Auth token for CTS API (required for real API) | -| `CDM_TASK_SERVICE_URL` | CTS API endpoint (defaults to CI) | -| `CTS_MOCK_MODE` | Set to `true` to enable mock mode | +| Variable | Description | +| ---------------------- | -------------------------------------------------------------------- | +| `KBASE_AUTH_TOKEN` | Auth token for CTS API (required for real API) | +| `CDM_TASK_SERVICE_URL` | CTS API endpoint (defaults to CI) | +| `CTS_MOCK_MODE` | Set to `true` to enable mock mode (auto-sets `hubUser` to `testuser`) | +| `NB_USER` | Username for S3-to-JupyterLab path mapping (set by JupyterHub) | +| `CDM_DEFAULT_BUCKET` | S3 bucket name for path mapping (default: `cdm-lake`) | #### Mock mode diff --git a/berdl_task_browser/__init__.py b/berdl_task_browser/__init__.py index 89aabfe..b4d48a6 100644 --- a/berdl_task_browser/__init__.py +++ b/berdl_task_browser/__init__.py @@ -46,6 +46,7 @@ def _load_jupyter_server_extension(server_app): if os.environ.get("CTS_MOCK_MODE", "").lower() in ("true", "1", "yes"): page_config["ctsMockMode"] = "true" + page_config.setdefault("hubUser", "testuser") if hub_user := os.environ.get("NB_USER"): page_config["hubUser"] = hub_user diff --git a/src/api/mockData.ts b/src/api/mockData.ts index 1468034..91c7746 100644 --- a/src/api/mockData.ts +++ b/src/api/mockData.ts @@ -65,11 +65,15 @@ export const MOCK_SITES: ISite[] = [ const createJobInput = ( cluster: string, image: string, - params?: Record + opts?: { + params?: Record; + input_files?: string[]; + output_dir?: string; + } ): IJobInput => ({ cluster, image, - params + ...opts }); export const MOCK_JOBS: IJob[] = [ @@ -88,8 +92,14 @@ export const MOCK_JOBS: IJob[] = [ registered_on: '2024-01-01T00:00:00Z' }, job_input: createJobInput('perlmutter-jaws', 'kbase/analysis-tool:v1.2.3', { - param1: 'value1', - param2: 42 + params: { param1: 'value1', param2: 42 }, + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/sample_001.fastq', + 's3a://cdm-lake/users-general-warehouse/testuser/data/sample_002.fastq', + 's3a://cdm-lake/users-general-warehouse/testuser/data/sample_003.fastq', + 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/reference_genome.fa', + 's3a://cdm-lake/tenant-sql-warehouse/kbase/exports/metadata.parquet' + ] }), cpu_factor: 1.0, max_memory: '8Gi', @@ -115,14 +125,20 @@ export const MOCK_JOBS: IJob[] = [ registered_by: 'admin', registered_on: '2024-01-01T00:00:00Z' }, - job_input: createJobInput( - 'perlmutter-jaws', - 'kbase/genome-assembler:v2.0.0' - ), + job_input: createJobInput('perlmutter-jaws', 'kbase/genome-assembler:v2.0.0', { + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R1.fastq', + 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R2.fastq', + 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/ref.fa' + ] + }), outputs: [ - { file: 'assembly.fasta', crc64nvme: 'abc123' }, - { file: 'stats.json' }, - { file: 'log.txt' } + { + file: 's3a://cdm-lake/users-general-warehouse/testuser/data/assembly.fasta', + crc64nvme: 'abc123' + }, + { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/stats.json' }, + { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/log.txt' } ], transition_times: createTransitionTimes([ { state: 'created', timeAgo: 180 }, @@ -149,10 +165,12 @@ export const MOCK_JOBS: IJob[] = [ registered_by: 'admin', registered_on: '2024-01-01T00:00:00Z' }, - job_input: createJobInput( - 'lawrencium-jaws', - 'kbase/memory-intensive:latest' - ), + job_input: createJobInput('lawrencium-jaws', 'kbase/memory-intensive:latest', { + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/big_dataset.h5', + 's3a://other-bucket/external/reference.txt' + ] + }), error: 'Container exited with non-zero status: OutOfMemoryError - Java heap space exceeded. Consider increasing memory allocation or reducing input size.', max_memory: '4Gi', @@ -244,7 +262,10 @@ export const MOCK_JOBS: IJob[] = [ registered_on: '2024-01-01T00:00:00Z' }, job_input: createJobInput('kbase', 'kbase/legacy-tool:v0.9.0'), - outputs: [{ file: 'result1.txt' }, { file: 'result2.txt' }], + outputs: [ + { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result1.txt' }, + { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result2.txt' } + ], transition_times: createTransitionTimes([ { state: 'created', timeAgo: 1500 }, { state: 'download_submitted', timeAgo: 1498 }, From 8da7b228a5fd64331e5d87251346ba0e9082ed69 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:12:28 -0800 Subject: [PATCH 03/11] Rename Browse S3 button to Browse Lakehouse Minio --- src/dialogs/JobWizardDialog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dialogs/JobWizardDialog.ts b/src/dialogs/JobWizardDialog.ts index 7f15b91..2cc34b6 100644 --- a/src/dialogs/JobWizardDialog.ts +++ b/src/dialogs/JobWizardDialog.ts @@ -146,7 +146,7 @@ export class JobWizardBody browseButton.type = 'button'; browseButton.className = 'jp-mod-styled jp-mod-accept jp-JobWizard-addArg'; - browseButton.textContent = 'Browse S3'; + browseButton.textContent = 'Browse Lakehouse Minio'; browseButton.addEventListener('click', () => this._browseS3()); buttonRow.appendChild(browseButton); } From d8c48ccabc7f2b26ec1ea061a8a7040b3764173e Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:40:03 -0800 Subject: [PATCH 04/11] Add server-side S3 path mapping endpoint, replace client-side conventions Add a Tornado handler at GET /api/task-browser/s3-path-mappings that reads the authoritative mapping from GroupedS3ContentsManager at runtime, with a governance API fallback. Replace the hardcoded client-side s3PathMapping module with a server-driven s3PathResolver that receives the mapping table from the server at activation. Register berdl-task-browser:navigate-to-path command for file browser navigation. --- berdl_task_browser/__init__.py | 3 + berdl_task_browser/handlers.py | 178 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + src/components/JobDetail.tsx | 7 +- src/dialogs/JobWizardDialog.ts | 7 +- src/index.ts | 19 +++- src/mocks/handlers.ts | 34 +++++++ src/types/window.ts | 2 + src/utils/fileBrowserNav.ts | 34 +++++++ src/utils/s3PathMapping.ts | 116 --------------------- src/utils/s3PathResolver.ts | 139 +++++++++++++++++++++++++ uv.lock | 2 + 12 files changed, 421 insertions(+), 121 deletions(-) create mode 100644 berdl_task_browser/handlers.py create mode 100644 src/utils/fileBrowserNav.ts delete mode 100644 src/utils/s3PathMapping.ts create mode 100644 src/utils/s3PathResolver.ts diff --git a/berdl_task_browser/__init__.py b/berdl_task_browser/__init__.py index b4d48a6..9a0e5f7 100644 --- a/berdl_task_browser/__init__.py +++ b/berdl_task_browser/__init__.py @@ -54,4 +54,7 @@ def _load_jupyter_server_extension(server_app): if bucket := os.environ.get("CDM_DEFAULT_BUCKET"): page_config["cdmDefaultBucket"] = bucket + from .handlers import setup_handlers + setup_handlers(server_app.web_app) + server_app.log.info("Registered berdl_task_browser server extension") diff --git a/berdl_task_browser/handlers.py b/berdl_task_browser/handlers.py new file mode 100644 index 0000000..9c217ec --- /dev/null +++ b/berdl_task_browser/handlers.py @@ -0,0 +1,178 @@ +""" +HTTP handlers for the BERDL Task Browser server extension. + +Provides a REST API endpoint that exposes the S3 path mappings from the +running GroupedS3ContentsManager, so the frontend can resolve S3 URLs to +JupyterLab file browser paths without duplicating mapping logic. +""" + +import json +import logging +from typing import Any + +from jupyter_server.base.handlers import APIHandler +from jupyter_server.utils import url_path_join +import tornado.web + +logger = logging.getLogger(__name__) + + +class BaseHandler(APIHandler): + """Base handler with common utilities.""" + + def write_json(self, data: dict[str, Any], status: int = 200) -> None: + """Write JSON response.""" + self.set_status(status) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(data)) + + def write_error_json(self, message: str, status: int = 500) -> None: + """Write JSON error response.""" + self.set_status(status) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"error": message})) + + +class S3PathMappingsHandler(BaseHandler): + """Handler for S3 path mappings from GroupedS3ContentsManager.""" + + @tornado.web.authenticated + def get(self) -> None: + """ + GET /api/task-browser/s3-path-mappings + + Returns the virtual directory -> bucket/prefix mapping from the + running GroupedS3ContentsManager. This is the authoritative mapping + computed at server startup. + """ + try: + mappings = self._get_mappings_from_contents_manager() + self.write_json({"mappings": mappings}) + except Exception as e: + logger.exception("Error reading S3 path mappings") + self.write_error_json(str(e), status=500) + + def _get_mappings_from_contents_manager(self) -> dict[str, Any]: + """Extract the S3 path mappings from the HybridContentsManager.""" + contents_manager = self.settings.get("contents_manager") + if contents_manager is None: + logger.warning("No contents_manager in server settings") + return {} + + # HybridContentsManager stores sub-managers in _managers dict. + # The lakehouse_minio entry is a GroupedS3ContentsManager whose + # .managers traitlet holds the raw config dict. + managers_dict = getattr(contents_manager, "_managers", None) + if managers_dict is None: + managers_dict = getattr(contents_manager, "managers", None) + + if not isinstance(managers_dict, dict): + logger.warning( + "Could not find _managers dict on contents_manager " + f"(type={type(contents_manager).__name__})" + ) + return self._fallback_governance_mappings() + + s3_cm = managers_dict.get("lakehouse_minio") + if s3_cm is None: + logger.warning("No lakehouse_minio entry in HybridContentsManager") + return self._fallback_governance_mappings() + + raw = getattr(s3_cm, "managers", None) + if not isinstance(raw, dict): + logger.warning( + "GroupedS3ContentsManager.managers is not a dict " + f"(type={type(raw).__name__})" + ) + return self._fallback_governance_mappings() + + return raw + + def _fallback_governance_mappings(self) -> dict[str, Any]: + """ + Fall back to deriving mappings from the governance API. + + This uses the same code path as jupyter_server_config.py — same + source of truth, just re-derived rather than read from the + already-computed config. + """ + try: + from berdl_notebook_utils.minio_governance import ( + get_my_groups, + get_my_workspace, + ) + import os + + username = os.environ.get("NB_USER", "") + bucket = os.environ.get("CDM_DEFAULT_BUCKET", "cdm-lake") + + mappings: dict[str, Any] = { + "my-files": { + "bucket": bucket, + "prefix": f"users-general-warehouse/{username}", + }, + "my-sql": { + "bucket": bucket, + "prefix": f"users-sql-warehouse/{username}", + }, + } + + try: + workspace = get_my_workspace() + groups = getattr(workspace, "groups", []) or [] + except Exception: + groups = [] + try: + groups_resp = get_my_groups() + groups = getattr(groups_resp, "groups", []) or [] + except Exception: + logger.warning("Could not fetch groups from governance API") + + for group in groups: + if isinstance(group, str): + group_name = group + is_ro = group_name.endswith("ro") + else: + group_name = getattr(group, "name", str(group)) + is_ro = group_name.endswith("ro") + + base_name = group_name[:-2] if is_ro else group_name + suffix = "-ro" if is_ro else "" + + mappings[f"{base_name}-files{suffix}"] = { + "bucket": bucket, + "prefix": f"tenant-general-warehouse/{base_name}", + **({"read_only": True} if is_ro else {}), + } + mappings[f"{base_name}-sql{suffix}"] = { + "bucket": bucket, + "prefix": f"tenant-sql-warehouse/{base_name}", + **({"read_only": True} if is_ro else {}), + } + + return mappings + + except ImportError: + logger.info( + "berdl_notebook_utils not available; returning empty mappings" + ) + return {} + except Exception as e: + logger.warning(f"Governance API fallback failed: {e}") + return {} + + +def setup_handlers(web_app: Any) -> None: + """Register handlers with the Jupyter server.""" + host_pattern = ".*$" + base_url = web_app.settings["base_url"] + + handlers = [ + ( + url_path_join(base_url, "api", "task-browser", "s3-path-mappings"), + S3PathMappingsHandler, + ), + ] + + web_app.add_handlers(host_pattern, handlers) + logger.info("BERDL Task Browser handlers registered") diff --git a/pyproject.toml b/pyproject.toml index 08ecdbe..b4ba94a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ ] dependencies = [ "ipywidgets>=8.0.0", + "jupyter_server>=2.0.0", "requests>=2.25.0", ] dynamic = ["version", "description", "authors", "urls", "keywords"] diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 3ce645a..779a9c5 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -22,7 +22,8 @@ import { StatusChip } from './StatusChip'; import { LogViewer, LogViewerEmpty } from './LogViewer'; import { useCancelJob, useJobExitCodes } from '../api/ctsApi'; import { MAX_DISPLAYED_OUTPUTS } from '../config'; -import { s3ToJlab } from '../utils/s3PathMapping'; +import { resolveS3ToJlab } from '../utils/s3PathResolver'; +import { IKBaseWindow } from '../types/window'; interface IJobDetailProps { job: IJob | undefined; @@ -84,7 +85,9 @@ const FileLink: React.FC<{ s3Path: string; onClick: (path: string) => void; }> = ({ s3Path, onClick }) => { - const jlabPath = s3ToJlab(s3Path); + const mappings = (window as unknown as IKBaseWindow).kbase?.task_browser + ?.s3Mappings; + const jlabPath = resolveS3ToJlab(s3Path, mappings ?? null); if (jlabPath) { return ( = { documentManager: IDocumentManager | null ) => { registerCTSNamespace(app); + registerNavigateCommand(app); + + // Fetch S3 path mappings from server (non-blocking) + const baseUrl = PageConfig.getBaseUrl(); + fetchS3Mappings(baseUrl) + .then(mappings => { + const cts = getCTSNamespace(); + if (cts) { + cts.s3Mappings = mappings; + } + }) + .catch(err => + console.warn('[CTS] S3 mapping discovery failed:', err) + ); // Create QueryClient for React Query (shared for embedded widgets) const queryClient = new QueryClient({ diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index e0c88d8..84c915e 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -11,7 +11,41 @@ import { MOCK_SITES } from '../api/mockData'; +const MOCK_S3_MAPPINGS = { + 'my-files': { + bucket: 'cdm-lake', + prefix: 'users-general-warehouse/testuser' + }, + 'my-sql': { + bucket: 'cdm-lake', + prefix: 'users-sql-warehouse/testuser' + }, + 'kbase-files': { + bucket: 'cdm-lake', + prefix: 'tenant-general-warehouse/kbase' + }, + 'kbase-sql': { + bucket: 'cdm-lake', + prefix: 'tenant-sql-warehouse/kbase' + }, + 'kbase-files-ro': { + bucket: 'cdm-lake', + prefix: 'tenant-general-warehouse/kbase', + read_only: true + }, + 'kbase-sql-ro': { + bucket: 'cdm-lake', + prefix: 'tenant-sql-warehouse/kbase', + read_only: true + } +}; + export const handlers = [ + // S3 path mappings (server extension endpoint) + http.get('*/api/task-browser/s3-path-mappings', () => { + return HttpResponse.json({ mappings: MOCK_S3_MAPPINGS }); + }), + // List sites http.get(`${CTS_API_BASE}/sites/`, () => { return HttpResponse.json({ sites: MOCK_SITES }); diff --git a/src/types/window.ts b/src/types/window.ts index 9e3a9a1..f7df6e1 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -3,6 +3,7 @@ */ import { JupyterFrontEnd } from '@jupyterlab/application'; +import { S3Mappings } from '../utils/s3PathResolver'; /** * CTS namespace interface for window.kbase.task_browser @@ -12,6 +13,7 @@ export interface ICTSNamespace { app: JupyterFrontEnd | null; selectJob: ((jobId: string) => void) | null; renderJobWidget: ((element: HTMLElement, jobId: string) => () => void) | null; + s3Mappings: S3Mappings | null; } /** diff --git a/src/utils/fileBrowserNav.ts b/src/utils/fileBrowserNav.ts new file mode 100644 index 0000000..c39cc98 --- /dev/null +++ b/src/utils/fileBrowserNav.ts @@ -0,0 +1,34 @@ +/** + * Utility for navigating the JupyterLab file browser to a given path. + */ + +import { JupyterFrontEnd } from '@jupyterlab/application'; + +export const COMMAND_NAVIGATE_TO_PATH = 'berdl-task-browser:navigate-to-path'; + +/** + * Register the navigate-to-path command on the app. + * Delegates to filebrowser:go-to-path internally. + */ +export function registerNavigateCommand(app: JupyterFrontEnd): void { + app.commands.addCommand(COMMAND_NAVIGATE_TO_PATH, { + label: 'Navigate to File Path', + execute: args => { + const path = args.path as string; + if (path) { + app.commands.execute('filebrowser:go-to-path', { path }); + } + } + }); +} + +/** + * Navigate the JupyterLab file browser to the given path. + * Uses the registered berdl-task-browser:navigate-to-path command. + */ +export function navigateFileBrowser( + app: JupyterFrontEnd, + jupyterLabPath: string +): void { + app.commands.execute(COMMAND_NAVIGATE_TO_PATH, { path: jupyterLabPath }); +} diff --git a/src/utils/s3PathMapping.ts b/src/utils/s3PathMapping.ts deleted file mode 100644 index a6d7a9d..0000000 --- a/src/utils/s3PathMapping.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Bidirectional path mapping between JupyterLab lakehouse_minio/ virtual - * directories and s3a:// URLs. - * - * Mapping convention (see GroupedS3ContentsManager): - * my-files <-> users-general-warehouse/{user} - * my-sql <-> users-sql-warehouse/{user} - * {t}-files[-ro] <-> tenant-general-warehouse/{t} - * {t}-sql[-ro] <-> tenant-sql-warehouse/{t} - */ - -import { PageConfig } from '@jupyterlab/coreutils'; - -const LAKEHOUSE = 'lakehouse_minio'; -const DEFAULT_BUCKET = 'cdm-lake'; - -function user(): string { - return PageConfig.getOption('hubUser') || ''; -} - -function bucket(): string { - return PageConfig.getOption('cdmDefaultBucket') || DEFAULT_BUCKET; -} - -/** Split "lakehouse_minio/manager-name/rest/of/path" into [manager, rest]. */ -function splitJlabPath(path: string): { manager: string; rest: string } | null { - if (!path.startsWith(`${LAKEHOUSE}/`)) { - return null; - } - const rel = path.slice(LAKEHOUSE.length + 1); - const i = rel.indexOf('/'); - if (i === -1) { - return null; - } - return { manager: rel.slice(0, i), rest: rel.slice(i + 1) }; -} - -/** Parse s3a://bucket/warehouse/entity/rest into parts. */ -function splitS3Url(url: string): { - urlBucket: string; - warehouse: string; - entity: string; - rest: string; -} | null { - const m = url.match(/^s3a?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(.*)$/); - if (!m) { - return null; - } - return { urlBucket: m[1], warehouse: m[2], entity: m[3], rest: m[4] }; -} - -/** - * Convert a JupyterLab lakehouse_minio/ path to an s3a:// URL. - * Returns null if the path is outside lakehouse_minio/ or unrecognised. - */ -export function jlabToS3(jlabPath: string): string | null { - const parts = splitJlabPath(jlabPath); - if (!parts) { - return null; - } - const { manager, rest } = parts; - const b = bucket(); - - if (manager === 'my-files') { - return `s3a://${b}/users-general-warehouse/${user()}/${rest}`; - } - if (manager === 'my-sql') { - return `s3a://${b}/users-sql-warehouse/${user()}/${rest}`; - } - - // Tenant: strip -files, -sql, -files-ro, -sql-ro suffix to get tenant name. - // Check longer suffixes first so -files-ro is matched before -files. - const suffixToWarehouse: [string, string][] = [ - ['-files-ro', 'tenant-general-warehouse'], - ['-sql-ro', 'tenant-sql-warehouse'], - ['-files', 'tenant-general-warehouse'], - ['-sql', 'tenant-sql-warehouse'] - ]; - for (const [suffix, warehouse] of suffixToWarehouse) { - if (manager.endsWith(suffix) && manager.length > suffix.length) { - const tenant = manager.slice(0, -suffix.length); - return `s3a://${b}/${warehouse}/${tenant}/${rest}`; - } - } - - return null; -} - -/** - * Convert an s3a:// (or s3://) URL to a JupyterLab lakehouse_minio/ path. - * Returns null if the URL doesn't map to any known virtual directory. - * - * For tenant paths, the RW manager name (e.g. "foo-files") is returned since - * we can't know from the URL alone whether the user has RO or RW access. - */ -export function s3ToJlab(s3Url: string): string | null { - const parts = splitS3Url(s3Url); - if (!parts || parts.urlBucket !== bucket()) { - return null; - } - const { warehouse, entity, rest } = parts; - const u = user(); - - switch (warehouse) { - case 'users-general-warehouse': - return entity === u ? `${LAKEHOUSE}/my-files/${rest}` : null; - case 'users-sql-warehouse': - return entity === u ? `${LAKEHOUSE}/my-sql/${rest}` : null; - case 'tenant-general-warehouse': - return `${LAKEHOUSE}/${entity}-files/${rest}`; - case 'tenant-sql-warehouse': - return `${LAKEHOUSE}/${entity}-sql/${rest}`; - default: - return null; - } -} diff --git a/src/utils/s3PathResolver.ts b/src/utils/s3PathResolver.ts new file mode 100644 index 0000000..fede8b4 --- /dev/null +++ b/src/utils/s3PathResolver.ts @@ -0,0 +1,139 @@ +/** + * Bidirectional S3 path resolution using server-provided mappings. + * + * All mapping data comes from the server endpoint + * GET /api/task-browser/s3-path-mappings — no mapping knowledge is baked in. + */ + +const LAKEHOUSE = 'lakehouse_minio'; + +export interface IS3Mapping { + bucket: string; + prefix: string; + read_only?: boolean; +} + +export type S3Mappings = Record; + +/** + * Convert an s3a:// (or s3://) URL to a JupyterLab lakehouse_minio/ path. + * + * Matches against the provided mappings using longest prefix first, and + * prefers RW mappings over RO when both match the same prefix. + * + * Returns null if no mapping matches. + */ +export function resolveS3ToJlab( + s3Url: string, + mappings: S3Mappings | null +): string | null { + if (!mappings) { + return null; + } + + const parsed = parseS3Url(s3Url); + if (!parsed) { + return null; + } + + const { bucket, key } = parsed; + + // Build candidate matches: [virtualDir, mapping, remainderPath] + type Match = { virtualDir: string; mapping: IS3Mapping; remainder: string }; + const matches: Match[] = []; + + for (const [virtualDir, mapping] of Object.entries(mappings)) { + if (mapping.bucket !== bucket) { + continue; + } + + const prefix = mapping.prefix.replace(/\/+$/, ''); + if (key === prefix || key.startsWith(prefix + '/')) { + const remainder = key === prefix ? '' : key.slice(prefix.length + 1); + matches.push({ virtualDir, mapping, remainder }); + } + } + + if (matches.length === 0) { + return null; + } + + // Sort: longest prefix first, then prefer RW (non-read_only) over RO + matches.sort((a, b) => { + const prefixLenDiff = b.mapping.prefix.length - a.mapping.prefix.length; + if (prefixLenDiff !== 0) { + return prefixLenDiff; + } + // Prefer RW over RO + const aRo = a.mapping.read_only ? 1 : 0; + const bRo = b.mapping.read_only ? 1 : 0; + return aRo - bRo; + }); + + const best = matches[0]; + const trail = best.remainder ? `/${best.remainder}` : ''; + return `${LAKEHOUSE}/${best.virtualDir}${trail}`; +} + +/** + * Convert a JupyterLab lakehouse_minio/ path to an s3a:// URL. + * + * Looks up the virtual directory name in the provided mappings. + * Returns null if the path is outside lakehouse_minio/ or unmapped. + */ +export function resolveJlabToS3( + jlabPath: string, + mappings: S3Mappings | null +): string | null { + if (!mappings) { + return null; + } + + if (!jlabPath.startsWith(`${LAKEHOUSE}/`)) { + return null; + } + + const rel = jlabPath.slice(LAKEHOUSE.length + 1); + const slashIdx = rel.indexOf('/'); + const virtualDir = slashIdx === -1 ? rel : rel.slice(0, slashIdx); + const remainder = slashIdx === -1 ? '' : rel.slice(slashIdx + 1); + + const mapping = mappings[virtualDir]; + if (!mapping) { + return null; + } + + const prefix = mapping.prefix.replace(/\/+$/, ''); + const trail = remainder ? `/${remainder}` : ''; + return `s3a://${mapping.bucket}/${prefix}${trail}`; +} + +/** + * Fetch S3 path mappings from the server endpoint. + */ +export async function fetchS3Mappings( + baseUrl: string +): Promise { + const url = `${baseUrl.replace(/\/+$/, '')}/api/task-browser/s3-path-mappings`; + try { + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) { + console.warn(`[CTS] S3 mappings endpoint returned ${response.status}`); + return null; + } + const data = await response.json(); + return data.mappings || null; + } catch (err) { + console.warn('[CTS] Failed to fetch S3 mappings:', err); + return null; + } +} + +/** Parse s3a:// or s3:// URL into bucket and key. */ +function parseS3Url(url: string): { bucket: string; key: string } | null { + const m = url.match(/^s3a?:\/\/([^/]+)\/(.+)$/); + if (!m) { + return null; + } + return { bucket: m[1], key: m[2] }; +} diff --git a/uv.lock b/uv.lock index f712718..b8a7a55 100644 --- a/uv.lock +++ b/uv.lock @@ -148,6 +148,7 @@ name = "berdl-task-browser" source = { editable = "." } dependencies = [ { name = "ipywidgets" }, + { name = "jupyter-server" }, { name = "requests" }, ] @@ -161,6 +162,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ipywidgets", specifier = ">=8.0.0" }, + { name = "jupyter-server", specifier = ">=2.0.0" }, { name = "requests", specifier = ">=2.25.0" }, ] From d0b08b4738481cd3a34c101005afcb75a1f3b599 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:53:33 -0800 Subject: [PATCH 05/11] Clean up S3 path mapping: remove dead code, simplify handler - Delete unused fileBrowserNav.ts (registered command never called) - Remove BaseHandler abstraction in handlers.py (single handler) - Remove unused hubUser/cdmDefaultBucket page_config entries - Remove redundant .catch() on fetchS3Mappings (handles errors internally) - Remove stale comment in s3PathResolver.ts --- README.md | 12 +-- berdl_task_browser/__init__.py | 7 -- berdl_task_browser/handlers.py | 189 +++++++++------------------------ src/index.ts | 19 ++-- src/utils/fileBrowserNav.ts | 34 ------ src/utils/s3PathResolver.ts | 1 - 6 files changed, 63 insertions(+), 199 deletions(-) delete mode 100644 src/utils/fileBrowserNav.ts diff --git a/README.md b/README.md index 45f88fd..d56ac2f 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,11 @@ With the watch command running, every saved change will immediately be built loc #### Environment variables -| Variable | Description | -| ---------------------- | -------------------------------------------------------------------- | -| `KBASE_AUTH_TOKEN` | Auth token for CTS API (required for real API) | -| `CDM_TASK_SERVICE_URL` | CTS API endpoint (defaults to CI) | -| `CTS_MOCK_MODE` | Set to `true` to enable mock mode (auto-sets `hubUser` to `testuser`) | -| `NB_USER` | Username for S3-to-JupyterLab path mapping (set by JupyterHub) | -| `CDM_DEFAULT_BUCKET` | S3 bucket name for path mapping (default: `cdm-lake`) | +| Variable | Description | +| ---------------------- | ---------------------------------------------- | +| `KBASE_AUTH_TOKEN` | Auth token for CTS API (required for real API) | +| `CDM_TASK_SERVICE_URL` | CTS API endpoint (defaults to CI) | +| `CTS_MOCK_MODE` | Set to `true` to enable mock mode | #### Mock mode diff --git a/berdl_task_browser/__init__.py b/berdl_task_browser/__init__.py index 9a0e5f7..7ed1c46 100644 --- a/berdl_task_browser/__init__.py +++ b/berdl_task_browser/__init__.py @@ -46,13 +46,6 @@ def _load_jupyter_server_extension(server_app): if os.environ.get("CTS_MOCK_MODE", "").lower() in ("true", "1", "yes"): page_config["ctsMockMode"] = "true" - page_config.setdefault("hubUser", "testuser") - - if hub_user := os.environ.get("NB_USER"): - page_config["hubUser"] = hub_user - - if bucket := os.environ.get("CDM_DEFAULT_BUCKET"): - page_config["cdmDefaultBucket"] = bucket from .handlers import setup_handlers setup_handlers(server_app.web_app) diff --git a/berdl_task_browser/handlers.py b/berdl_task_browser/handlers.py index 9c217ec..3b602d9 100644 --- a/berdl_task_browser/handlers.py +++ b/berdl_task_browser/handlers.py @@ -1,13 +1,8 @@ -""" -HTTP handlers for the BERDL Task Browser server extension. - -Provides a REST API endpoint that exposes the S3 path mappings from the -running GroupedS3ContentsManager, so the frontend can resolve S3 URLs to -JupyterLab file browser paths without duplicating mapping logic. -""" +"""S3 path mapping endpoint for the BERDL Task Browser server extension.""" import json import logging +import os from typing import Any from jupyter_server.base.handlers import APIHandler @@ -17,162 +12,82 @@ logger = logging.getLogger(__name__) -class BaseHandler(APIHandler): - """Base handler with common utilities.""" - - def write_json(self, data: dict[str, Any], status: int = 200) -> None: - """Write JSON response.""" - self.set_status(status) - self.set_header("Content-Type", "application/json") - self.finish(json.dumps(data)) - - def write_error_json(self, message: str, status: int = 500) -> None: - """Write JSON error response.""" - self.set_status(status) - self.set_header("Content-Type", "application/json") - self.finish(json.dumps({"error": message})) - - -class S3PathMappingsHandler(BaseHandler): - """Handler for S3 path mappings from GroupedS3ContentsManager.""" +class S3PathMappingsHandler(APIHandler): + """Exposes S3 path mappings from GroupedS3ContentsManager.""" @tornado.web.authenticated def get(self) -> None: - """ - GET /api/task-browser/s3-path-mappings - - Returns the virtual directory -> bucket/prefix mapping from the - running GroupedS3ContentsManager. This is the authoritative mapping - computed at server startup. - """ try: - mappings = self._get_mappings_from_contents_manager() - self.write_json({"mappings": mappings}) + self.finish(json.dumps({"mappings": self._get_mappings()})) except Exception as e: logger.exception("Error reading S3 path mappings") - self.write_error_json(str(e), status=500) + self.set_status(500) + self.finish(json.dumps({"error": str(e)})) - def _get_mappings_from_contents_manager(self) -> dict[str, Any]: - """Extract the S3 path mappings from the HybridContentsManager.""" - contents_manager = self.settings.get("contents_manager") - if contents_manager is None: - logger.warning("No contents_manager in server settings") - return {} + def _get_mappings(self) -> dict[str, Any]: + cm = self.settings.get("contents_manager") + if cm is None: + return self._fallback_mappings() # HybridContentsManager stores sub-managers in _managers dict. - # The lakehouse_minio entry is a GroupedS3ContentsManager whose - # .managers traitlet holds the raw config dict. - managers_dict = getattr(contents_manager, "_managers", None) - if managers_dict is None: - managers_dict = getattr(contents_manager, "managers", None) - - if not isinstance(managers_dict, dict): - logger.warning( - "Could not find _managers dict on contents_manager " - f"(type={type(contents_manager).__name__})" - ) - return self._fallback_governance_mappings() + managers = getattr(cm, "_managers", None) + if managers is None: + managers = getattr(cm, "managers", None) + if not isinstance(managers, dict): + return self._fallback_mappings() - s3_cm = managers_dict.get("lakehouse_minio") + s3_cm = managers.get("lakehouse_minio") if s3_cm is None: - logger.warning("No lakehouse_minio entry in HybridContentsManager") - return self._fallback_governance_mappings() + return self._fallback_mappings() raw = getattr(s3_cm, "managers", None) - if not isinstance(raw, dict): - logger.warning( - "GroupedS3ContentsManager.managers is not a dict " - f"(type={type(raw).__name__})" - ) - return self._fallback_governance_mappings() - - return raw - - def _fallback_governance_mappings(self) -> dict[str, Any]: - """ - Fall back to deriving mappings from the governance API. + return raw if isinstance(raw, dict) else self._fallback_mappings() - This uses the same code path as jupyter_server_config.py — same - source of truth, just re-derived rather than read from the - already-computed config. - """ + def _fallback_mappings(self) -> dict[str, Any]: + """Derive mappings from governance API when ContentsManager unavailable.""" try: from berdl_notebook_utils.minio_governance import ( get_my_groups, get_my_workspace, ) - import os - - username = os.environ.get("NB_USER", "") - bucket = os.environ.get("CDM_DEFAULT_BUCKET", "cdm-lake") - - mappings: dict[str, Any] = { - "my-files": { - "bucket": bucket, - "prefix": f"users-general-warehouse/{username}", - }, - "my-sql": { - "bucket": bucket, - "prefix": f"users-sql-warehouse/{username}", - }, - } + except ImportError: + return {} + username = os.environ.get("NB_USER", "") + bucket = os.environ.get("CDM_DEFAULT_BUCKET", "cdm-lake") + + mappings: dict[str, Any] = { + "my-files": {"bucket": bucket, "prefix": f"users-general-warehouse/{username}"}, + "my-sql": {"bucket": bucket, "prefix": f"users-sql-warehouse/{username}"}, + } + + try: + groups = getattr(get_my_workspace(), "groups", []) or [] + except Exception: try: - workspace = get_my_workspace() - groups = getattr(workspace, "groups", []) or [] + groups = getattr(get_my_groups(), "groups", []) or [] except Exception: groups = [] - try: - groups_resp = get_my_groups() - groups = getattr(groups_resp, "groups", []) or [] - except Exception: - logger.warning("Could not fetch groups from governance API") - - for group in groups: - if isinstance(group, str): - group_name = group - is_ro = group_name.endswith("ro") - else: - group_name = getattr(group, "name", str(group)) - is_ro = group_name.endswith("ro") - - base_name = group_name[:-2] if is_ro else group_name - suffix = "-ro" if is_ro else "" - - mappings[f"{base_name}-files{suffix}"] = { - "bucket": bucket, - "prefix": f"tenant-general-warehouse/{base_name}", - **({"read_only": True} if is_ro else {}), - } - mappings[f"{base_name}-sql{suffix}"] = { - "bucket": bucket, - "prefix": f"tenant-sql-warehouse/{base_name}", - **({"read_only": True} if is_ro else {}), - } - - return mappings - except ImportError: - logger.info( - "berdl_notebook_utils not available; returning empty mappings" - ) - return {} - except Exception as e: - logger.warning(f"Governance API fallback failed: {e}") - return {} + for group in groups: + name = group if isinstance(group, str) else getattr(group, "name", str(group)) + is_ro = name.endswith("ro") + base = name[:-2] if is_ro else name + suffix = "-ro" if is_ro else "" + ro_flag = {"read_only": True} if is_ro else {} + mappings[f"{base}-files{suffix}"] = { + "bucket": bucket, "prefix": f"tenant-general-warehouse/{base}", **ro_flag, + } + mappings[f"{base}-sql{suffix}"] = { + "bucket": bucket, "prefix": f"tenant-sql-warehouse/{base}", **ro_flag, + } + + return mappings def setup_handlers(web_app: Any) -> None: """Register handlers with the Jupyter server.""" - host_pattern = ".*$" base_url = web_app.settings["base_url"] - - handlers = [ - ( - url_path_join(base_url, "api", "task-browser", "s3-path-mappings"), - S3PathMappingsHandler, - ), - ] - - web_app.add_handlers(host_pattern, handlers) - logger.info("BERDL Task Browser handlers registered") + web_app.add_handlers(".*$", [ + (url_path_join(base_url, "api", "task-browser", "s3-path-mappings"), S3PathMappingsHandler), + ]) diff --git a/src/index.ts b/src/index.ts index db2fd36..6bb1781 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ import { PageConfig } from '@jupyterlab/coreutils'; import { getToken } from './auth/token'; import { ICTSNamespace, IKBaseWindow } from './types/window'; import { fetchS3Mappings } from './utils/s3PathResolver'; -import { registerNavigateCommand } from './utils/fileBrowserNav'; // Re-export types for external consumers export type { ICTSNamespace, IKBaseWindow }; @@ -159,20 +158,14 @@ const plugin: JupyterFrontEndPlugin = { documentManager: IDocumentManager | null ) => { registerCTSNamespace(app); - registerNavigateCommand(app); // Fetch S3 path mappings from server (non-blocking) - const baseUrl = PageConfig.getBaseUrl(); - fetchS3Mappings(baseUrl) - .then(mappings => { - const cts = getCTSNamespace(); - if (cts) { - cts.s3Mappings = mappings; - } - }) - .catch(err => - console.warn('[CTS] S3 mapping discovery failed:', err) - ); + fetchS3Mappings(PageConfig.getBaseUrl()).then(mappings => { + const cts = getCTSNamespace(); + if (cts) { + cts.s3Mappings = mappings; + } + }); // Create QueryClient for React Query (shared for embedded widgets) const queryClient = new QueryClient({ diff --git a/src/utils/fileBrowserNav.ts b/src/utils/fileBrowserNav.ts deleted file mode 100644 index c39cc98..0000000 --- a/src/utils/fileBrowserNav.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Utility for navigating the JupyterLab file browser to a given path. - */ - -import { JupyterFrontEnd } from '@jupyterlab/application'; - -export const COMMAND_NAVIGATE_TO_PATH = 'berdl-task-browser:navigate-to-path'; - -/** - * Register the navigate-to-path command on the app. - * Delegates to filebrowser:go-to-path internally. - */ -export function registerNavigateCommand(app: JupyterFrontEnd): void { - app.commands.addCommand(COMMAND_NAVIGATE_TO_PATH, { - label: 'Navigate to File Path', - execute: args => { - const path = args.path as string; - if (path) { - app.commands.execute('filebrowser:go-to-path', { path }); - } - } - }); -} - -/** - * Navigate the JupyterLab file browser to the given path. - * Uses the registered berdl-task-browser:navigate-to-path command. - */ -export function navigateFileBrowser( - app: JupyterFrontEnd, - jupyterLabPath: string -): void { - app.commands.execute(COMMAND_NAVIGATE_TO_PATH, { path: jupyterLabPath }); -} diff --git a/src/utils/s3PathResolver.ts b/src/utils/s3PathResolver.ts index fede8b4..0c77eaf 100644 --- a/src/utils/s3PathResolver.ts +++ b/src/utils/s3PathResolver.ts @@ -38,7 +38,6 @@ export function resolveS3ToJlab( const { bucket, key } = parsed; - // Build candidate matches: [virtualDir, mapping, remainderPath] type Match = { virtualDir: string; mapping: IS3Mapping; remainder: string }; const matches: Match[] = []; From ca2ebc72ca536e5f3251244955fbcb4e30305394 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:02:32 -0800 Subject: [PATCH 06/11] Add development commands table to README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index d56ac2f..8a6c741 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,18 @@ uv run jupyter lab With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). +#### Development commands + +| Command | Description | +| ------------------------ | ------------------------------ | +| `uv run jlpm build` | Build extension (dev mode) | +| `uv run jlpm build:prod` | Build extension (production) | +| `uv run jlpm watch` | Watch and rebuild on changes | +| `uv run jlpm lint` | Run all linters with auto-fix | +| `uv run jlpm lint:check` | Run all linters without fixing | +| `uv run jlpm test` | Run Jest tests with coverage | +| `uv run jlpm clean:all` | Clean all build artifacts | + #### Environment variables | Variable | Description | From 979c8e458546c618f662cc78350dd46e7c305135 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:32:23 -0800 Subject: [PATCH 07/11] Serve MSW service worker in mock mode --- berdl_task_browser/handlers.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/berdl_task_browser/handlers.py b/berdl_task_browser/handlers.py index 3b602d9..b17b3eb 100644 --- a/berdl_task_browser/handlers.py +++ b/berdl_task_browser/handlers.py @@ -85,9 +85,38 @@ def _fallback_mappings(self) -> dict[str, Any]: return mappings +class MockServiceWorkerHandler(tornado.web.RequestHandler): + """Serve MSW's mockServiceWorker.js at the site root for mock mode.""" + + def get(self) -> None: + try: + import importlib.resources + msw_path = importlib.resources.files("msw").joinpath("lib", "mockServiceWorker.js") + self.set_header("Content-Type", "application/javascript") + self.finish(msw_path.read_text()) + except Exception: + # Fallback: find it relative to node_modules + import pathlib + candidates = list(pathlib.Path(__file__).parent.parent.glob( + "node_modules/msw/lib/mockServiceWorker.js" + )) + if candidates: + self.set_header("Content-Type", "application/javascript") + self.finish(candidates[0].read_text()) + else: + self.set_status(404) + self.finish("mockServiceWorker.js not found") + + def setup_handlers(web_app: Any) -> None: """Register handlers with the Jupyter server.""" base_url = web_app.settings["base_url"] - web_app.add_handlers(".*$", [ + handlers = [ (url_path_join(base_url, "api", "task-browser", "s3-path-mappings"), S3PathMappingsHandler), - ]) + ] + + # Serve MSW service worker in mock mode + if os.environ.get("CTS_MOCK_MODE", "").lower() in ("true", "1", "yes"): + handlers.append(("/mockServiceWorker.js", MockServiceWorkerHandler)) + + web_app.add_handlers(".*$", handlers) From 33334b79d223b3e4fad71215d21a9617b958f056 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:17:26 -0800 Subject: [PATCH 08/11] Move file picker from nested dialog to top-level wizard button, fix lint Refactor Browse Lakehouse Minio from an embedded button inside the wizard body (which opened a nested dialog) to a top-level dialog button that closes the wizard, opens the file picker, then reopens with updated form data. Also apply prettier formatting to mock data. --- src/api/mockData.ts | 46 +++++++++++++------- src/dialogs/JobWizardDialog.ts | 76 ++++++++++++++++------------------ 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/src/api/mockData.ts b/src/api/mockData.ts index 91c7746..d7539c6 100644 --- a/src/api/mockData.ts +++ b/src/api/mockData.ts @@ -125,19 +125,25 @@ export const MOCK_JOBS: IJob[] = [ registered_by: 'admin', registered_on: '2024-01-01T00:00:00Z' }, - job_input: createJobInput('perlmutter-jaws', 'kbase/genome-assembler:v2.0.0', { - input_files: [ - 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R1.fastq', - 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R2.fastq', - 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/ref.fa' - ] - }), + job_input: createJobInput( + 'perlmutter-jaws', + 'kbase/genome-assembler:v2.0.0', + { + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R1.fastq', + 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R2.fastq', + 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/ref.fa' + ] + } + ), outputs: [ { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/assembly.fasta', crc64nvme: 'abc123' }, - { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/stats.json' }, + { + file: 's3a://cdm-lake/users-general-warehouse/testuser/data/stats.json' + }, { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/log.txt' } ], transition_times: createTransitionTimes([ @@ -165,12 +171,16 @@ export const MOCK_JOBS: IJob[] = [ registered_by: 'admin', registered_on: '2024-01-01T00:00:00Z' }, - job_input: createJobInput('lawrencium-jaws', 'kbase/memory-intensive:latest', { - input_files: [ - 's3a://cdm-lake/users-general-warehouse/testuser/data/big_dataset.h5', - 's3a://other-bucket/external/reference.txt' - ] - }), + job_input: createJobInput( + 'lawrencium-jaws', + 'kbase/memory-intensive:latest', + { + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/big_dataset.h5', + 's3a://other-bucket/external/reference.txt' + ] + } + ), error: 'Container exited with non-zero status: OutOfMemoryError - Java heap space exceeded. Consider increasing memory allocation or reducing input size.', max_memory: '4Gi', @@ -263,8 +273,12 @@ export const MOCK_JOBS: IJob[] = [ }, job_input: createJobInput('kbase', 'kbase/legacy-tool:v0.9.0'), outputs: [ - { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result1.txt' }, - { file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result2.txt' } + { + file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result1.txt' + }, + { + file: 's3a://cdm-lake/users-general-warehouse/testuser/data/result2.txt' + } ], transition_times: createTransitionTimes([ { state: 'created', timeAgo: 1500 }, diff --git a/src/dialogs/JobWizardDialog.ts b/src/dialogs/JobWizardDialog.ts index c0b6505..adaf19b 100644 --- a/src/dialogs/JobWizardDialog.ts +++ b/src/dialogs/JobWizardDialog.ts @@ -73,14 +73,9 @@ export class JobWizardBody private _argsContainer!: HTMLDivElement; private _errorDisplay!: HTMLDivElement; private _args: string[] = []; - private _documentManager: IDocumentManager | null; - constructor( - initialData?: IWizardFormData, - documentManager?: IDocumentManager | null - ) { + constructor(initialData?: IWizardFormData) { super(); - this._documentManager = documentManager ?? null; this.addClass('jp-JobWizard'); this._buildForm(); if (initialData) { @@ -142,15 +137,6 @@ export class JobWizardBody addInputFileButton.addEventListener('click', () => this._addInputFile()); buttonRow.appendChild(addInputFileButton); - if (this._documentManager) { - const browseButton = document.createElement('button'); - browseButton.type = 'button'; - browseButton.className = - 'jp-mod-styled jp-mod-accept jp-JobWizard-addArg'; - browseButton.textContent = 'Browse Lakehouse Minio'; - browseButton.addEventListener('click', () => this._browseS3()); - buttonRow.appendChild(browseButton); - } inputFilesSection.appendChild(buttonRow); @@ -345,26 +331,6 @@ export class JobWizardBody }); } - private async _browseS3(): Promise { - if (!this._documentManager) { - return; - } - const result = await FileDialog.getOpenFiles({ - manager: this._documentManager, - defaultPath: 'lakehouse_minio/', - title: 'Select Input Files' - }); - if (result.button.accept && result.value) { - for (const model of result.value) { - const mappings = (window as unknown as IKBaseWindow).kbase - ?.task_browser?.s3Mappings; - const s3Url = resolveJlabToS3(model.path, mappings ?? null); - if (s3Url) { - this._addInputFile(s3Url); - } - } - } - } private _addArg(value = ''): void { const index = this._args.length; @@ -499,19 +465,47 @@ export async function showJobWizardDialog( const reopen = (data: IWizardFormData) => showJobWizardDialog(notebookTracker, data, documentManager); - const body = new JobWizardBody(initialData, documentManager); + const body = new JobWizardBody(initialData); + + const buttons = [ + Dialog.cancelButton(), + ...(documentManager + ? [Dialog.createButton({ label: 'Browse Lakehouse Minio' })] + : []), + Dialog.createButton({ label: 'Source' }), + Dialog.okButton({ label: 'Insert' }) + ]; const result = await showDialog({ title: 'Create CTS Job', body, - buttons: [ - Dialog.cancelButton(), - Dialog.createButton({ label: 'Source' }), - Dialog.okButton({ label: 'Insert' }) - ], + buttons, focusNodeSelector: 'input[name="image"]' }); + if (result.button.label === 'Browse Lakehouse Minio') { + const formData = body.getValue(); + + const fileResult = await FileDialog.getOpenFiles({ + manager: documentManager!, + defaultPath: 'lakehouse_minio/', + title: 'Select Input Files' + }); + + if (fileResult.button.accept && fileResult.value) { + const mappings = (window as unknown as IKBaseWindow).kbase?.task_browser + ?.s3Mappings; + for (const model of fileResult.value) { + const s3Url = resolveJlabToS3(model.path, mappings ?? null); + if (s3Url) { + formData.inputFiles.push(s3Url); + } + } + } + + return reopen(formData); + } + if (result.button.label === 'Source') { if (!body.validate()) { return reopen(body.getValue()); From 2d15dfb3a005ce3078891a7cbad108715e78fad9 Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:40:15 -0800 Subject: [PATCH 09/11] Fix review issues: React Query for S3 mappings, remove dead code, tests - Replace fire-and-forget window.kbase.task_browser.s3Mappings with useS3Mappings React Query hook, passing mappings via props - Remove _fallback_mappings() (dead code: governance API unavailable when ContentsManager fails; MSW handles mock mode client-side) - Show inline error when Browse files can't be resolved to S3 paths - Extract shared _removeRow helper from duplicate removal methods - Simplify MockServiceWorkerHandler (remove dead importlib path) - Rename MAX_DISPLAYED_OUTPUTS to MAX_DISPLAYED_FILES - Fix non-null assertion lint error in JobWizardDialog - Add s3PathResolver unit tests (19 tests) --- berdl_task_browser/handlers.py | 77 ++------- src/api/ctsApi.ts | 12 ++ src/components/CTSBrowser.tsx | 13 +- src/components/JobDetail.tsx | 36 ++-- src/config.ts | 2 +- src/dialogs/JobWizardDialog.ts | 67 ++++---- src/index.ts | 12 +- src/types/window.ts | 2 - src/utils/__tests__/s3PathResolver.spec.ts | 181 +++++++++++++++++++++ 9 files changed, 282 insertions(+), 120 deletions(-) create mode 100644 src/utils/__tests__/s3PathResolver.spec.ts diff --git a/berdl_task_browser/handlers.py b/berdl_task_browser/handlers.py index b17b3eb..95ebeb5 100644 --- a/berdl_task_browser/handlers.py +++ b/berdl_task_browser/handlers.py @@ -27,85 +27,40 @@ def get(self) -> None: def _get_mappings(self) -> dict[str, Any]: cm = self.settings.get("contents_manager") if cm is None: - return self._fallback_mappings() + return {} # HybridContentsManager stores sub-managers in _managers dict. managers = getattr(cm, "_managers", None) if managers is None: managers = getattr(cm, "managers", None) if not isinstance(managers, dict): - return self._fallback_mappings() + return {} s3_cm = managers.get("lakehouse_minio") if s3_cm is None: - return self._fallback_mappings() - - raw = getattr(s3_cm, "managers", None) - return raw if isinstance(raw, dict) else self._fallback_mappings() - - def _fallback_mappings(self) -> dict[str, Any]: - """Derive mappings from governance API when ContentsManager unavailable.""" - try: - from berdl_notebook_utils.minio_governance import ( - get_my_groups, - get_my_workspace, - ) - except ImportError: return {} - username = os.environ.get("NB_USER", "") - bucket = os.environ.get("CDM_DEFAULT_BUCKET", "cdm-lake") - - mappings: dict[str, Any] = { - "my-files": {"bucket": bucket, "prefix": f"users-general-warehouse/{username}"}, - "my-sql": {"bucket": bucket, "prefix": f"users-sql-warehouse/{username}"}, - } - - try: - groups = getattr(get_my_workspace(), "groups", []) or [] - except Exception: - try: - groups = getattr(get_my_groups(), "groups", []) or [] - except Exception: - groups = [] - - for group in groups: - name = group if isinstance(group, str) else getattr(group, "name", str(group)) - is_ro = name.endswith("ro") - base = name[:-2] if is_ro else name - suffix = "-ro" if is_ro else "" - ro_flag = {"read_only": True} if is_ro else {} - mappings[f"{base}-files{suffix}"] = { - "bucket": bucket, "prefix": f"tenant-general-warehouse/{base}", **ro_flag, - } - mappings[f"{base}-sql{suffix}"] = { - "bucket": bucket, "prefix": f"tenant-sql-warehouse/{base}", **ro_flag, - } - - return mappings + raw = getattr(s3_cm, "managers", None) + return raw if isinstance(raw, dict) else {} class MockServiceWorkerHandler(tornado.web.RequestHandler): """Serve MSW's mockServiceWorker.js at the site root for mock mode.""" def get(self) -> None: - try: - import importlib.resources - msw_path = importlib.resources.files("msw").joinpath("lib", "mockServiceWorker.js") - self.set_header("Content-Type", "application/javascript") - self.finish(msw_path.read_text()) - except Exception: - # Fallback: find it relative to node_modules - import pathlib - candidates = list(pathlib.Path(__file__).parent.parent.glob( + import pathlib + + candidates = list( + pathlib.Path(__file__).parent.parent.glob( "node_modules/msw/lib/mockServiceWorker.js" - )) - if candidates: - self.set_header("Content-Type", "application/javascript") - self.finish(candidates[0].read_text()) - else: - self.set_status(404) - self.finish("mockServiceWorker.js not found") + ) + ) + if candidates: + self.set_header("Content-Type", "application/javascript") + self.finish(candidates[0].read_text()) + else: + self.set_status(404) + self.finish("mockServiceWorker.js not found") def setup_handlers(web_app: Any) -> None: diff --git a/src/api/ctsApi.ts b/src/api/ctsApi.ts index 2214543..a0275c4 100644 --- a/src/api/ctsApi.ts +++ b/src/api/ctsApi.ts @@ -3,12 +3,14 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { PageConfig } from '@jupyterlab/coreutils'; import { CTS_API_BASE, POLLING_INTERVAL_ACTIVE, POLLING_INTERVAL_LIST, DEFAULT_JOB_LIMIT } from '../config'; +import { S3Mappings, fetchS3Mappings } from '../utils/s3PathResolver'; import { IJob, IJobStatus, @@ -234,3 +236,13 @@ export function useJobLog( staleTime: 30000 // Cache logs for 30 seconds }); } + +export function useS3Mappings(): S3Mappings | null { + const baseUrl = PageConfig.getBaseUrl(); + const { data } = useQuery({ + queryKey: ['s3-mappings'], + queryFn: () => fetchS3Mappings(baseUrl), + staleTime: Infinity + }); + return data ?? null; +} diff --git a/src/components/CTSBrowser.tsx b/src/components/CTSBrowser.tsx index 3329fbc..6662ffc 100644 --- a/src/components/CTSBrowser.tsx +++ b/src/components/CTSBrowser.tsx @@ -5,7 +5,7 @@ import { IDocumentManager } from '@jupyterlab/docmanager'; import { Box, Typography, IconButton, Tooltip } from '@mui/material'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'; -import { useJobs, useJobDetail } from '../api/ctsApi'; +import { useJobs, useJobDetail, useS3Mappings } from '../api/ctsApi'; import { IJobFilters } from '../types/jobs'; import { JobFilters } from './JobFilters'; import { JobList } from './JobList'; @@ -37,6 +37,7 @@ export const CTSBrowser: React.FC = ({ const jobsQuery = useJobs(filters); const jobDetailQuery = useJobDetail(selectedJobId); + const s3Mappings = useS3Mappings(); // Handlers const handleFiltersChange = useCallback((newFilters: IJobFilters) => { @@ -56,8 +57,13 @@ export const CTSBrowser: React.FC = ({ }, []); const handleOpenWizard = useCallback(() => { - showJobWizardDialog(notebookTracker, undefined, documentManager); - }, [notebookTracker, documentManager]); + showJobWizardDialog( + notebookTracker, + undefined, + documentManager, + s3Mappings + ); + }, [notebookTracker, documentManager, s3Mappings]); const statusSummary = useMemo(() => { const jobs = jobsQuery.data; @@ -154,6 +160,7 @@ export const CTSBrowser: React.FC = ({ error={jobDetailQuery.error} onClose={handleCloseDetail} app={app} + s3Mappings={s3Mappings} /> )} diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 779a9c5..2858a9d 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -21,9 +21,8 @@ import { import { StatusChip } from './StatusChip'; import { LogViewer, LogViewerEmpty } from './LogViewer'; import { useCancelJob, useJobExitCodes } from '../api/ctsApi'; -import { MAX_DISPLAYED_OUTPUTS } from '../config'; -import { resolveS3ToJlab } from '../utils/s3PathResolver'; -import { IKBaseWindow } from '../types/window'; +import { MAX_DISPLAYED_FILES } from '../config'; +import { resolveS3ToJlab, S3Mappings } from '../utils/s3PathResolver'; interface IJobDetailProps { job: IJob | undefined; @@ -31,6 +30,7 @@ interface IJobDetailProps { error: Error | null; onClose: () => void; app: JupyterFrontEnd; + s3Mappings: S3Mappings | null; } const formatTimestamp = (isoString: string): string => { @@ -84,10 +84,9 @@ const fileSx = { const FileLink: React.FC<{ s3Path: string; onClick: (path: string) => void; -}> = ({ s3Path, onClick }) => { - const mappings = (window as unknown as IKBaseWindow).kbase?.task_browser - ?.s3Mappings; - const jlabPath = resolveS3ToJlab(s3Path, mappings ?? null); + mappings: S3Mappings | null; +}> = ({ s3Path, onClick, mappings }) => { + const jlabPath = resolveS3ToJlab(s3Path, mappings); if (jlabPath) { return ( = ({ isLoading, error, onClose, - app + app, + s3Mappings }) => { const [showAllOutputs, setShowAllOutputs] = useState(false); const [showAllInputs, setShowAllInputs] = useState(false); @@ -392,13 +392,17 @@ export const JobDetail: React.FC = ({ {(showAllInputs ? job.job_input.input_files - : job.job_input.input_files.slice(0, MAX_DISPLAYED_OUTPUTS) + : job.job_input.input_files.slice(0, MAX_DISPLAYED_FILES) ).map((file, index) => ( - + ))} - {job.job_input.input_files.length > MAX_DISPLAYED_OUTPUTS && ( + {job.job_input.input_files.length > MAX_DISPLAYED_FILES && ( setShowAllInputs(!showAllInputs)} @@ -424,13 +428,17 @@ export const JobDetail: React.FC = ({ {(showAllOutputs ? job.outputs - : job.outputs.slice(0, MAX_DISPLAYED_OUTPUTS) + : job.outputs.slice(0, MAX_DISPLAYED_FILES) ).map((output, index) => ( - + ))} - {job.outputs.length > MAX_DISPLAYED_OUTPUTS && ( + {job.outputs.length > MAX_DISPLAYED_FILES && ( setShowAllOutputs(!showAllOutputs)} diff --git a/src/config.ts b/src/config.ts index 2dee0ea..f1f626c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,7 @@ export const POLLING_INTERVAL_LIST = 30000; // 30 seconds for job list export const DEFAULT_JOB_LIMIT = 100; // UI limits -export const MAX_DISPLAYED_OUTPUTS = 5; +export const MAX_DISPLAYED_FILES = 5; export const LOG_MAX_HEIGHT = 150; // Auth diff --git a/src/dialogs/JobWizardDialog.ts b/src/dialogs/JobWizardDialog.ts index adaf19b..6fa0b98 100644 --- a/src/dialogs/JobWizardDialog.ts +++ b/src/dialogs/JobWizardDialog.ts @@ -7,8 +7,7 @@ import { insertCodeCell, generateJobCreationCode } from '../utils/notebookUtils'; -import { resolveJlabToS3 } from '../utils/s3PathResolver'; -import { IKBaseWindow } from '../types/window'; +import { resolveJlabToS3, S3Mappings } from '../utils/s3PathResolver'; export type MemoryUnit = 'GB' | 'MB' | 'Gi' | 'Mi'; @@ -74,13 +73,16 @@ export class JobWizardBody private _errorDisplay!: HTMLDivElement; private _args: string[] = []; - constructor(initialData?: IWizardFormData) { + constructor(initialData?: IWizardFormData, initialError?: string) { super(); this.addClass('jp-JobWizard'); this._buildForm(); if (initialData) { this._populateForm(initialData); } + if (initialError) { + this._showError(initialError); + } } private _populateForm(data: IWizardFormData): void { @@ -136,8 +138,6 @@ export class JobWizardBody addInputFileButton.textContent = '+ Add input file'; addInputFileButton.addEventListener('click', () => this._addInputFile()); buttonRow.appendChild(addInputFileButton); - - inputFilesSection.appendChild(buttonRow); this.node.appendChild(inputFilesSection); @@ -318,19 +318,22 @@ export class JobWizardBody this._inputFilesContainer.appendChild(row); } - private _removeInputFile(row: HTMLDivElement): void { + private _removeRow( + container: HTMLDivElement, + array: string[], + row: HTMLDivElement + ): void { const index = parseInt(row.dataset.index || '0', 10); - this._inputFiles.splice(index, 1); + array.splice(index, 1); row.remove(); - - const rows = this._inputFilesContainer.querySelectorAll( - '.jp-JobWizard-argRow' - ); - rows.forEach((r, i) => { + container.querySelectorAll('.jp-JobWizard-argRow').forEach((r, i) => { (r as HTMLDivElement).dataset.index = String(i); }); } + private _removeInputFile(row: HTMLDivElement): void { + this._removeRow(this._inputFilesContainer, this._inputFiles, row); + } private _addArg(value = ''): void { const index = this._args.length; @@ -363,14 +366,7 @@ export class JobWizardBody } private _removeArg(row: HTMLDivElement): void { - const index = parseInt(row.dataset.index || '0', 10); - this._args.splice(index, 1); - row.remove(); - - const rows = this._argsContainer.querySelectorAll('.jp-JobWizard-argRow'); - rows.forEach((r, i) => { - (r as HTMLDivElement).dataset.index = String(i); - }); + this._removeRow(this._argsContainer, this._args, row); } private _showError(message: string): void { @@ -460,12 +456,20 @@ export class JobWizardBody export async function showJobWizardDialog( notebookTracker: INotebookTracker | null, initialData?: IWizardFormData, - documentManager?: IDocumentManager | null + documentManager?: IDocumentManager | null, + s3Mappings?: S3Mappings | null, + errorMessage?: string ): Promise { - const reopen = (data: IWizardFormData) => - showJobWizardDialog(notebookTracker, data, documentManager); + const reopen = (data: IWizardFormData, error?: string) => + showJobWizardDialog( + notebookTracker, + data, + documentManager, + s3Mappings, + error + ); - const body = new JobWizardBody(initialData); + const body = new JobWizardBody(initialData, errorMessage); const buttons = [ Dialog.cancelButton(), @@ -487,20 +491,27 @@ export async function showJobWizardDialog( const formData = body.getValue(); const fileResult = await FileDialog.getOpenFiles({ - manager: documentManager!, + manager: documentManager as IDocumentManager, defaultPath: 'lakehouse_minio/', title: 'Select Input Files' }); if (fileResult.button.accept && fileResult.value) { - const mappings = (window as unknown as IKBaseWindow).kbase?.task_browser - ?.s3Mappings; + const failed: string[] = []; for (const model of fileResult.value) { - const s3Url = resolveJlabToS3(model.path, mappings ?? null); + const s3Url = resolveJlabToS3(model.path, s3Mappings ?? null); if (s3Url) { formData.inputFiles.push(s3Url); + } else { + failed.push(model.path); } } + if (failed.length > 0) { + return reopen( + formData, + `Could not resolve to S3 paths: ${failed.join(', ')}` + ); + } } return reopen(formData); diff --git a/src/index.ts b/src/index.ts index 6bb1781..72e9dfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,6 @@ import { faBarsProgress } from '@fortawesome/free-solid-svg-icons'; import { PageConfig } from '@jupyterlab/coreutils'; import { getToken } from './auth/token'; import { ICTSNamespace, IKBaseWindow } from './types/window'; -import { fetchS3Mappings } from './utils/s3PathResolver'; // Re-export types for external consumers export type { ICTSNamespace, IKBaseWindow }; @@ -126,8 +125,7 @@ function registerCTSNamespace(app: JupyterFrontEnd): void { getToken: getToken, app: app, selectJob: null, - renderJobWidget: renderJobWidget, - s3Mappings: null + renderJobWidget: renderJobWidget }; win.kbase = { @@ -159,14 +157,6 @@ const plugin: JupyterFrontEndPlugin = { ) => { registerCTSNamespace(app); - // Fetch S3 path mappings from server (non-blocking) - fetchS3Mappings(PageConfig.getBaseUrl()).then(mappings => { - const cts = getCTSNamespace(); - if (cts) { - cts.s3Mappings = mappings; - } - }); - // Create QueryClient for React Query (shared for embedded widgets) const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/types/window.ts b/src/types/window.ts index f7df6e1..9e3a9a1 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -3,7 +3,6 @@ */ import { JupyterFrontEnd } from '@jupyterlab/application'; -import { S3Mappings } from '../utils/s3PathResolver'; /** * CTS namespace interface for window.kbase.task_browser @@ -13,7 +12,6 @@ export interface ICTSNamespace { app: JupyterFrontEnd | null; selectJob: ((jobId: string) => void) | null; renderJobWidget: ((element: HTMLElement, jobId: string) => () => void) | null; - s3Mappings: S3Mappings | null; } /** diff --git a/src/utils/__tests__/s3PathResolver.spec.ts b/src/utils/__tests__/s3PathResolver.spec.ts new file mode 100644 index 0000000..18aa21b --- /dev/null +++ b/src/utils/__tests__/s3PathResolver.spec.ts @@ -0,0 +1,181 @@ +import { + resolveS3ToJlab, + resolveJlabToS3, + fetchS3Mappings, + S3Mappings +} from '../s3PathResolver'; + +const MAPPINGS: S3Mappings = { + 'my-files': { + bucket: 'cdm-lake', + prefix: 'users-general-warehouse/alice' + }, + 'my-sql': { bucket: 'cdm-lake', prefix: 'users-sql-warehouse/alice' }, + 'kbase-files': { + bucket: 'cdm-lake', + prefix: 'tenant-general-warehouse/kbase' + }, + 'kbase-sql': { + bucket: 'cdm-lake', + prefix: 'tenant-sql-warehouse/kbase' + }, + 'kbase-files-ro': { + bucket: 'cdm-lake', + prefix: 'tenant-general-warehouse/kbase', + read_only: true + }, + 'kbase-sql-ro': { + bucket: 'cdm-lake', + prefix: 'tenant-sql-warehouse/kbase', + read_only: true + } +}; + +describe('resolveS3ToJlab', () => { + it('returns null when mappings are null', () => { + expect(resolveS3ToJlab('s3a://cdm-lake/foo', null)).toBeNull(); + }); + + it('returns null for invalid URL scheme', () => { + expect(resolveS3ToJlab('https://foo/bar', MAPPINGS)).toBeNull(); + }); + + it('returns null when no bucket matches', () => { + expect(resolveS3ToJlab('s3a://other-bucket/foo/bar', MAPPINGS)).toBeNull(); + }); + + it('resolves a basic s3a:// path', () => { + expect( + resolveS3ToJlab( + 's3a://cdm-lake/users-general-warehouse/alice/data/file.csv', + MAPPINGS + ) + ).toBe('lakehouse_minio/my-files/data/file.csv'); + }); + + it('resolves s3:// scheme', () => { + expect( + resolveS3ToJlab( + 's3://cdm-lake/users-general-warehouse/alice/f.txt', + MAPPINGS + ) + ).toBe('lakehouse_minio/my-files/f.txt'); + }); + + it('resolves exact prefix with no remainder', () => { + expect( + resolveS3ToJlab('s3a://cdm-lake/users-general-warehouse/alice', MAPPINGS) + ).toBe('lakehouse_minio/my-files'); + }); + + it('prefers RW over RO when both match', () => { + expect( + resolveS3ToJlab( + 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/f.txt', + MAPPINGS + ) + ).toBe('lakehouse_minio/kbase-files/shared/f.txt'); + }); + + it('does not match on prefix boundary (no partial match)', () => { + expect( + resolveS3ToJlab( + 's3a://cdm-lake/users-general-warehouse/alicex/f', + MAPPINGS + ) + ).toBeNull(); + }); + + it('handles trailing slash on prefix', () => { + const mappings: S3Mappings = { + test: { bucket: 'b', prefix: 'foo/' } + }; + expect(resolveS3ToJlab('s3a://b/foo/bar', mappings)).toBe( + 'lakehouse_minio/test/bar' + ); + }); +}); + +describe('resolveJlabToS3', () => { + it('returns null when mappings are null', () => { + expect(resolveJlabToS3('lakehouse_minio/my-files/f.txt', null)).toBeNull(); + }); + + it('returns null for non-lakehouse path', () => { + expect(resolveJlabToS3('home/file.txt', MAPPINGS)).toBeNull(); + }); + + it('resolves a basic lakehouse path', () => { + expect( + resolveJlabToS3('lakehouse_minio/my-files/data/file.csv', MAPPINGS) + ).toBe('s3a://cdm-lake/users-general-warehouse/alice/data/file.csv'); + }); + + it('returns null for unmapped virtualDir', () => { + expect( + resolveJlabToS3('lakehouse_minio/unknown/f.txt', MAPPINGS) + ).toBeNull(); + }); + + it('resolves virtualDir only (no subpath)', () => { + expect(resolveJlabToS3('lakehouse_minio/my-files', MAPPINGS)).toBe( + 's3a://cdm-lake/users-general-warehouse/alice' + ); + }); +}); + +describe('fetchS3Mappings', () => { + const originalFetch = window.fetch; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + afterEach(() => { + window.fetch = originalFetch; + warnSpy.mockClear(); + }); + + afterAll(() => { + warnSpy.mockRestore(); + }); + + it('returns mappings on success', async () => { + const expected: S3Mappings = { + 'my-files': { bucket: 'cdm-lake', prefix: 'users-general-warehouse/bob' } + }; + window.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ mappings: expected }) + }); + + const result = await fetchS3Mappings('http://localhost:8888/'); + expect(result).toEqual(expected); + }); + + it('returns null on HTTP error', async () => { + window.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500 + }); + + const result = await fetchS3Mappings('http://localhost:8888/'); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('returns null on network error', async () => { + window.fetch = jest.fn().mockRejectedValue(new Error('network down')); + + const result = await fetchS3Mappings('http://localhost:8888/'); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('returns null when mappings key is missing', async () => { + window.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}) + }); + + const result = await fetchS3Mappings('http://localhost:8888/'); + expect(result).toBeNull(); + }); +}); From 350fadd8c3208b80854d366339fad4b30601a26d Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:06:42 -0800 Subject: [PATCH 10/11] Fix review items: trampoline wizard, reset job detail, remove dead code - Replace recursive reopen closure in wizard dialog with iterative trampoline pattern (runWizardStep + while loop) - Add key={selectedJobId} to JobDetail so expand state resets on job change - Remove dead browseRow div wrapper and its CSS rule - Add output_dir to three mock jobs for realistic test data --- src/api/mockData.ts | 9 ++-- src/components/CTSBrowser.tsx | 1 + src/dialogs/JobWizardDialog.ts | 88 +++++++++++++++++++++------------- style/base.css | 7 --- 4 files changed, 61 insertions(+), 44 deletions(-) diff --git a/src/api/mockData.ts b/src/api/mockData.ts index d7539c6..7d7db4a 100644 --- a/src/api/mockData.ts +++ b/src/api/mockData.ts @@ -99,7 +99,8 @@ export const MOCK_JOBS: IJob[] = [ 's3a://cdm-lake/users-general-warehouse/testuser/data/sample_003.fastq', 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/reference_genome.fa', 's3a://cdm-lake/tenant-sql-warehouse/kbase/exports/metadata.parquet' - ] + ], + output_dir: '/output' }), cpu_factor: 1.0, max_memory: '8Gi', @@ -133,7 +134,8 @@ export const MOCK_JOBS: IJob[] = [ 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R1.fastq', 's3a://cdm-lake/users-general-warehouse/testuser/data/reads_R2.fastq', 's3a://cdm-lake/tenant-general-warehouse/kbase/shared/ref.fa' - ] + ], + output_dir: '/results' } ), outputs: [ @@ -178,7 +180,8 @@ export const MOCK_JOBS: IJob[] = [ input_files: [ 's3a://cdm-lake/users-general-warehouse/testuser/data/big_dataset.h5', 's3a://other-bucket/external/reference.txt' - ] + ], + output_dir: '/output' } ), error: diff --git a/src/components/CTSBrowser.tsx b/src/components/CTSBrowser.tsx index 6662ffc..6a93a41 100644 --- a/src/components/CTSBrowser.tsx +++ b/src/components/CTSBrowser.tsx @@ -155,6 +155,7 @@ export const CTSBrowser: React.FC = ({ }} > this._addInputFile()); - buttonRow.appendChild(addInputFileButton); - inputFilesSection.appendChild(buttonRow); + inputFilesSection.appendChild(addInputFileButton); this.node.appendChild(inputFilesSection); @@ -453,23 +449,18 @@ export class JobWizardBody } } -export async function showJobWizardDialog( - notebookTracker: INotebookTracker | null, - initialData?: IWizardFormData, - documentManager?: IDocumentManager | null, - s3Mappings?: S3Mappings | null, - errorMessage?: string -): Promise { - const reopen = (data: IWizardFormData, error?: string) => - showJobWizardDialog( - notebookTracker, - data, - documentManager, - s3Mappings, - error - ); +type WizardOutcome = + | { shouldClose: true; result: boolean } + | { shouldClose: false; data: IWizardFormData; error?: string }; - const body = new JobWizardBody(initialData, errorMessage); +async function runWizardStep( + notebookTracker: INotebookTracker | null, + data: IWizardFormData | undefined, + error: string | undefined, + documentManager: IDocumentManager | null | undefined, + s3Mappings: S3Mappings | null | undefined +): Promise { + const body = new JobWizardBody(data, error); const buttons = [ Dialog.cancelButton(), @@ -507,19 +498,20 @@ export async function showJobWizardDialog( } } if (failed.length > 0) { - return reopen( - formData, - `Could not resolve to S3 paths: ${failed.join(', ')}` - ); + return { + shouldClose: false, + data: formData, + error: `Could not resolve to S3 paths: ${failed.join(', ')}` + }; } } - return reopen(formData); + return { shouldClose: false, data: formData }; } if (result.button.label === 'Source') { if (!body.validate()) { - return reopen(body.getValue()); + return { shouldClose: false, data: body.getValue() }; } const formData = body.getValue(); @@ -556,17 +548,17 @@ export async function showJobWizardDialog( buttons: [Dialog.okButton({ label: 'Close' })] }); - return reopen(formData); + return { shouldClose: false, data: formData }; } if (result.button.accept) { if (!body.validate()) { - return reopen(body.getValue()); + return { shouldClose: false, data: body.getValue() }; } const formData = result.value; if (!formData) { - return false; + return { shouldClose: true, result: false }; } if (!notebookTracker?.currentWidget) { @@ -575,7 +567,7 @@ export async function showJobWizardDialog( body: 'No active notebook found. Please open a notebook first.', buttons: [Dialog.okButton()] }); - return reopen(formData); + return { shouldClose: false, data: formData }; } const code = generateJobCreationCode({ @@ -595,11 +587,39 @@ export async function showJobWizardDialog( body: 'Failed to insert code into notebook', buttons: [Dialog.okButton()] }); - return reopen(formData); + return { shouldClose: false, data: formData }; } - return true; + return { shouldClose: true, result: true }; } - return false; + return { shouldClose: true, result: false }; +} + +export async function showJobWizardDialog( + notebookTracker: INotebookTracker | null, + initialData?: IWizardFormData, + documentManager?: IDocumentManager | null, + s3Mappings?: S3Mappings | null, + errorMessage?: string +): Promise { + let data = initialData; + let error = errorMessage; + let result: boolean | undefined; + while (result === undefined) { + const outcome = await runWizardStep( + notebookTracker, + data, + error, + documentManager, + s3Mappings + ); + if (outcome.shouldClose) { + result = outcome.result; + } else { + data = outcome.data; + error = outcome.error; + } + } + return result; } diff --git a/style/base.css b/style/base.css index 42f9b88..752e926 100644 --- a/style/base.css +++ b/style/base.css @@ -204,10 +204,3 @@ border-color: var(--jp-success-color1); color: var(--jp-success-color1); } - -/* Button row for input file actions */ -.jp-JobWizard-browseRow { - display: flex; - gap: 8px; - align-items: center; -} From 5e24464f652e06a8452d9d9733982f4f8f889dca Mon Sep 17 00:00:00 2001 From: David Lyon <5115845+dauglyon@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:17:29 -0800 Subject: [PATCH 11/11] Don't leak exception details in S3 mappings error response --- berdl_task_browser/handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/berdl_task_browser/handlers.py b/berdl_task_browser/handlers.py index 95ebeb5..f6db6a7 100644 --- a/berdl_task_browser/handlers.py +++ b/berdl_task_browser/handlers.py @@ -19,10 +19,10 @@ class S3PathMappingsHandler(APIHandler): def get(self) -> None: try: self.finish(json.dumps({"mappings": self._get_mappings()})) - except Exception as e: + except Exception: logger.exception("Error reading S3 path mappings") self.set_status(500) - self.finish(json.dumps({"error": str(e)})) + self.finish(json.dumps({"error": "Internal server error"})) def _get_mappings(self) -> dict[str, Any]: cm = self.settings.get("contents_manager")