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 | diff --git a/berdl_task_browser/__init__.py b/berdl_task_browser/__init__.py index 76c0303..7ed1c46 100644 --- a/berdl_task_browser/__init__.py +++ b/berdl_task_browser/__init__.py @@ -47,4 +47,7 @@ def _load_jupyter_server_extension(server_app): if os.environ.get("CTS_MOCK_MODE", "").lower() in ("true", "1", "yes"): page_config["ctsMockMode"] = "true" + 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..f6db6a7 --- /dev/null +++ b/berdl_task_browser/handlers.py @@ -0,0 +1,77 @@ +"""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 +from jupyter_server.utils import url_path_join +import tornado.web + +logger = logging.getLogger(__name__) + + +class S3PathMappingsHandler(APIHandler): + """Exposes S3 path mappings from GroupedS3ContentsManager.""" + + @tornado.web.authenticated + def get(self) -> None: + try: + self.finish(json.dumps({"mappings": self._get_mappings()})) + except Exception: + logger.exception("Error reading S3 path mappings") + self.set_status(500) + self.finish(json.dumps({"error": "Internal server error"})) + + def _get_mappings(self) -> dict[str, Any]: + cm = self.settings.get("contents_manager") + if cm is None: + 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 {} + + s3_cm = managers.get("lakehouse_minio") + if s3_cm is None: + return {} + + 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: + 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"] + 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) 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/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/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/api/mockData.ts b/src/api/mockData.ts index 1468034..7d7db4a 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,15 @@ 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' + ], + output_dir: '/output' }), cpu_factor: 1.0, max_memory: '8Gi', @@ -117,12 +128,25 @@ export const MOCK_JOBS: IJob[] = [ }, job_input: createJobInput( 'perlmutter-jaws', - 'kbase/genome-assembler:v2.0.0' + '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' + ], + output_dir: '/results' + } ), 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 }, @@ -151,7 +175,14 @@ export const MOCK_JOBS: IJob[] = [ }, job_input: createJobInput( 'lawrencium-jaws', - 'kbase/memory-intensive:latest' + 'kbase/memory-intensive:latest', + { + input_files: [ + 's3a://cdm-lake/users-general-warehouse/testuser/data/big_dataset.h5', + 's3a://other-bucket/external/reference.txt' + ], + output_dir: '/output' + } ), error: 'Container exited with non-zero status: OutOfMemoryError - Java heap space exceeded. Consider increasing memory allocation or reducing input size.', @@ -244,7 +275,14 @@ 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 }, diff --git a/src/components/CTSBrowser.tsx b/src/components/CTSBrowser.tsx index 8af2539..6a93a41 100644 --- a/src/components/CTSBrowser.tsx +++ b/src/components/CTSBrowser.tsx @@ -1,9 +1,11 @@ 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'; -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'; @@ -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); @@ -29,6 +37,7 @@ export const CTSBrowser: React.FC = ({ notebookTracker }) => { const jobsQuery = useJobs(filters); const jobDetailQuery = useJobDetail(selectedJobId); + const s3Mappings = useS3Mappings(); // Handlers const handleFiltersChange = useCallback((newFilters: IJobFilters) => { @@ -48,8 +57,13 @@ export const CTSBrowser: React.FC = ({ notebookTracker }) => { }, []); const handleOpenWizard = useCallback(() => { - showJobWizardDialog(notebookTracker); - }, [notebookTracker]); + showJobWizardDialog( + notebookTracker, + undefined, + documentManager, + s3Mappings + ); + }, [notebookTracker, documentManager, s3Mappings]); const statusSummary = useMemo(() => { const jobs = jobsQuery.data; @@ -141,10 +155,13 @@ export const CTSBrowser: React.FC = ({ notebookTracker }) => { }} > )} diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 73b4184..2858a9d 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 { @@ -20,13 +21,16 @@ import { import { StatusChip } from './StatusChip'; import { LogViewer, LogViewerEmpty } from './LogViewer'; import { useCancelJob, useJobExitCodes } from '../api/ctsApi'; -import { MAX_DISPLAYED_OUTPUTS } from '../config'; +import { MAX_DISPLAYED_FILES } from '../config'; +import { resolveS3ToJlab, S3Mappings } from '../utils/s3PathResolver'; interface IJobDetailProps { job: IJob | undefined; isLoading: boolean; error: Error | null; onClose: () => void; + app: JupyterFrontEnd; + s3Mappings: S3Mappings | null; } const formatTimestamp = (isoString: string): string => { @@ -71,13 +75,42 @@ 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; + mappings: S3Mappings | null; +}> = ({ s3Path, onClick, mappings }) => { + const jlabPath = resolveS3ToJlab(s3Path, mappings); + 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, + s3Mappings }) => { const [showAllOutputs, setShowAllOutputs] = useState(false); + const [showAllInputs, setShowAllInputs] = useState(false); const cancelMutation = useCancelJob(); const exitCodesQuery = useJobExitCodes( job?.id || null, @@ -90,6 +123,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 +383,44 @@ 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_FILES) + ).map((file, index) => ( + + + + ))} + {job.job_input.input_files.length > MAX_DISPLAYED_FILES && ( + 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 && ( @@ -350,21 +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) => ( - - {output.file} - + ))} - {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 b2ea4a7..fa6e3bf 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 { resolveJlabToS3, S3Mappings } from '../utils/s3PathResolver'; export type MemoryUnit = 'GB' | 'MB' | 'Gi' | 'Mi'; @@ -70,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 { @@ -308,19 +314,23 @@ 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; this._args.push(value); @@ -352,14 +362,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 { @@ -446,26 +449,69 @@ export class JobWizardBody } } -export async function showJobWizardDialog( +type WizardOutcome = + | { shouldClose: true; result: boolean } + | { shouldClose: false; data: IWizardFormData; error?: string }; + +async function runWizardStep( notebookTracker: INotebookTracker | null, - initialData?: IWizardFormData -): Promise { - const body = new JobWizardBody(initialData); + 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(), + ...(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 as IDocumentManager, + defaultPath: 'lakehouse_minio/', + title: 'Select Input Files' + }); + + if (fileResult.button.accept && fileResult.value) { + const failed: string[] = []; + for (const model of fileResult.value) { + const s3Url = resolveJlabToS3(model.path, s3Mappings ?? null); + if (s3Url) { + formData.inputFiles.push(s3Url); + } else { + failed.push(model.path); + } + } + if (failed.length > 0) { + return { + shouldClose: false, + data: formData, + error: `Could not resolve to S3 paths: ${failed.join(', ')}` + }; + } + } + + return { shouldClose: false, data: formData }; + } + if (result.button.label === 'Source') { if (!body.validate()) { - return showJobWizardDialog(notebookTracker, body.getValue()); + return { shouldClose: false, data: body.getValue() }; } const formData = body.getValue(); @@ -502,17 +548,17 @@ export async function showJobWizardDialog( buttons: [Dialog.okButton({ label: 'Close' })] }); - return showJobWizardDialog(notebookTracker, formData); + return { shouldClose: false, data: formData }; } if (result.button.accept) { if (!body.validate()) { - return showJobWizardDialog(notebookTracker, body.getValue()); + return { shouldClose: false, data: body.getValue() }; } const formData = result.value; if (!formData) { - return false; + return { shouldClose: true, result: false }; } if (!notebookTracker?.currentWidget) { @@ -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 { shouldClose: false, data: formData }; } const code = generateJobCreationCode({ @@ -541,11 +587,39 @@ export async function showJobWizardDialog( body: 'Failed to insert code into notebook', buttons: [Dialog.okButton()] }); - return showJobWizardDialog(notebookTracker, 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/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/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/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(); + }); +}); diff --git a/src/utils/s3PathResolver.ts b/src/utils/s3PathResolver.ts new file mode 100644 index 0000000..0c77eaf --- /dev/null +++ b/src/utils/s3PathResolver.ts @@ -0,0 +1,138 @@ +/** + * 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; + + 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" }, ] 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