diff --git a/.gitignore b/.gitignore index aee250e..a8d7318 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ deployment/helm/ skaffold.yaml .vscode .overrides -.DS_Store \ No newline at end of file +.DS_Store +CLAUDE.md +CLAUDE.local.md diff --git a/applications/idp-arc/frontend/.gitignore b/applications/idp-arc/frontend/.gitignore new file mode 100644 index 0000000..ccb30e3 --- /dev/null +++ b/applications/idp-arc/frontend/.gitignore @@ -0,0 +1,2 @@ +.env.* + diff --git a/applications/idp-arc/frontend/package.json b/applications/idp-arc/frontend/package.json index 6601eb8..10520e7 100644 --- a/applications/idp-arc/frontend/package.json +++ b/applications/idp-arc/frontend/package.json @@ -22,7 +22,8 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^17.0.2", - "react-router-dom": "^7.14.2" + "react-router-dom": "^7.14.2", + "spark-md5": "^3.0.2" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -30,6 +31,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/spark-md5": "^3.0.5", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -39,4 +41,4 @@ "typescript-eslint": "^8.48.0", "vite": "^7.3.1" } -} +} \ No newline at end of file diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index eba1946..334f684 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -18,42 +18,46 @@ import { KeycloakAuthClient } from '../infra/keycloakAuthClient' import { WorkspaceApiClient } from '../infra/workspaceApiClient' -import { JupyterApiClient } from '../infra/jupyterApiClient' +import { DandiApiClient } from '../infra/dandiApiClient' import { createLoadWorkspacesUseCase } from '../core/use-cases/loadWorkspaces' -import { createCreateAndUploadUseCase } from '../core/use-cases/createAndUploadWorkspace' +import { createCreateAndUploadToDandiUseCase } from '../core/use-cases/createAndUploadToDandi' +import { createCreateWorkspaceUseCase } from '../core/use-cases/createWorkspace' // ─── Config ─────────────────────────────────────────────────────────────────── // All environment-specific URLs live here (or read from import.meta.env in Vite). -const BASE_DOMAIN = 'v2dev.opensourcebrain.org' -const WWW_BASE = import.meta.env.DEV ? '/api-proxy' : `https://www.${BASE_DOMAIN}` +const BASE_DOMAIN = import.meta.env.VITE_OSB_BASE_DOMAIN ?? 'v2dev.opensourcebrain.org' +const PROTOCOL = import.meta.env.VITE_OSB_PROTOCOL ?? 'https' +const WWW_BASE = import.meta.env.DEV ? '/api-proxy' : `${PROTOCOL}://www.${BASE_DOMAIN}` const WORKSPACES_API = `${WWW_BASE}/proxy/workspaces/api` const WORKSPACES_LIST_URL = `${WWW_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=` -const JUPYTER_BASE = '/jupyter-proxy' -// JupyterHub named-server suffix — the subdomain appname of the JupyterHub host. -// For lab.v2dev.opensourcebrain.org workspace 764 spawns as server "764lab". -const JUPYTER_SERVER_SUFFIX = 'lab' -const FRONTEND_BASE = `https://www.${BASE_DOMAIN}` +const FRONTEND_BASE = `${PROTOCOL}://www.${BASE_DOMAIN}` // ─── Infrastructure singletons ──────────────────────────────────────────────── export const authClient = new KeycloakAuthClient({ - url: 'https://accounts.v2dev.opensourcebrain.org', - realm: 'osb2dev', + url: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://accounts.v2dev.opensourcebrain.org', + realm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'osb2dev', clientId: 'idp-arc', }) const workspaceApi = new WorkspaceApiClient(WORKSPACES_API, WORKSPACES_LIST_URL) -const jupyterApi = new JupyterApiClient(JUPYTER_BASE, BASE_DOMAIN) +// DANDI upload endpoints live in OSB's `workspaces` app (the admin key has to sit wherever +// they run, per Dario) — same API base as every other workspace call. +const dandiApi = new DandiApiClient(WORKSPACES_API) // ─── Use-cases (injected with their concrete dependencies) ──────────────────── /** Refreshes the token then returns the workspace list. */ export const loadWorkspaces = createLoadWorkspacesUseCase(authClient, workspaceApi) -/** Runs the 4-step create-workspace + file-upload workflow. */ -export const createAndUpload = createCreateAndUploadUseCase(authClient, workspaceApi, jupyterApi, JUPYTER_SERVER_SUFFIX) +/** Creates a new, empty workspace with the given name. */ +export const createWorkspace = createCreateWorkspaceUseCase(authClient, workspaceApi) + +/** DANDI-backed upload (Route A, see IDP-43 notes); `finalize` also runs the selected + * protocol's script server-side (jupyter_kernel_client.py in OSBv2's workspaces app). */ +export const createAndUploadToDandi = createCreateAndUploadToDandiUseCase(authClient, dandiApi) // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/applications/idp-arc/frontend/src/app/mockContainer.ts b/applications/idp-arc/frontend/src/app/mockContainer.ts index 6b55877..3a9b14d 100644 --- a/applications/idp-arc/frontend/src/app/mockContainer.ts +++ b/applications/idp-arc/frontend/src/app/mockContainer.ts @@ -10,21 +10,21 @@ import { MockAuthClient } from '../infra/mocks/mockAuthClient' import { MockWorkspaceApiClient } from '../infra/mocks/mockWorkspaceApiClient' -import { MockJupyterApiClient } from '../infra/mocks/mockJupyterApiClient' +import { MockDandiApiClient } from '../infra/mocks/mockDandiApiClient' import { createLoadWorkspacesUseCase } from '../core/use-cases/loadWorkspaces' -import { createCreateAndUploadUseCase } from '../core/use-cases/createAndUploadWorkspace' +import { createCreateAndUploadToDandiUseCase } from '../core/use-cases/createAndUploadToDandi' // ─── Mock infrastructure singletons ────────────────────────────────────────── export const authClient = new MockAuthClient() const workspaceApi = new MockWorkspaceApiClient() -const jupyterApi = new MockJupyterApiClient() +const dandiApi = new MockDandiApiClient() // ─── Use-cases (same factory functions, different adapters) ─────────────────── export const loadWorkspaces = createLoadWorkspacesUseCase(authClient, workspaceApi) -export const createAndUpload = createCreateAndUploadUseCase(authClient, workspaceApi, jupyterApi) +export const createAndUploadToDandi = createCreateAndUploadToDandiUseCase(authClient, dandiApi) // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx index 055223e..6be1e92 100644 --- a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -16,7 +16,7 @@ import CloseIcon from '@mui/icons-material/Close' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' -import { createAndUpload, getWorkspaceUrl, loadWorkspaces } from '../app/container' +import { createAndUploadToDandi, getWorkspaceUrl, loadWorkspaces } from '../app/container' import { useAppContext } from '../AppContext' import type { Workspace } from '../core/types' import protocols from '../data/protocols.json' @@ -92,54 +92,53 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data window.focus() } + /** Slug for the asset path prefix — protocols.json has no stable id field, so derive one. */ + const slugify = (s: string) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + + /** The analysis script that ships into the workspace, chosen by the selected protocol. + * Every protocol currently points at the same placeholder script; the per-protocol pipelines + * replace the URLs in protocols.json without touching this code. */ + const selectedScript = protocols.find((p) => p.name === (protocol || behavioralTask)) + const handleUpload = async () => { if (!file) return setForm((prev) => ({ ...prev, step: 'uploading', uploadMessage: '' })) abortRef.current = false - // `spawnedWorkspaceId` is set after the first upload attempt this session. - // On retry we reuse that workspace so the user can fix a stuck JupyterLab - // without causing a new workspace to be spawned on every attempt. + // `spawnedWorkspaceId` is set once finalize succeeds this session. Unlike the old + // JupyterLab flow, the workspace no longer exists until the DANDI upload has fully + // completed — it's the last thing `finalize` does, not the first step — so there is + // nothing to reuse on a retry before the first successful attempt. const isRetry = spawnedWorkspaceId !== undefined const selectedWorkspace = workspaces.find(w => String(w.id) === String(workspaceId)) const newWorkspaceName = [behavioralTask, protocol].filter(Boolean).join(' — ') || 'New Workspace' const resolvedId = selectedWorkspace ? (typeof selectedWorkspace.id === 'string' ? parseInt(selectedWorkspace.id, 10) : selectedWorkspace.id) : undefined - - // On retry reuse the previously spawned workspace; otherwise use the selected one. const uploadWorkspaceId = isRetry ? spawnedWorkspaceId : resolvedId - // Open the workspace tab only on the first attempt — on retry it is already open. - if (!isRetry && uploadWorkspaceId !== undefined) { - openWorkspaceTab(uploadWorkspaceId) - } - - await createAndUpload( + // finalize is synchronous: it also spawns the workspace and runs the script, so this + // one call can block for minutes. + await createAndUploadToDandi( { - workspaceName: selectedWorkspace?.name ?? newWorkspaceName, - workspaceId: uploadWorkspaceId, + taskId: slugify(protocol || behavioralTask), file, - userId: tokenParsed?.sub as string, - // For new workspaces: track the id and open the tab the moment it is created. - onWorkspaceCreated: uploadWorkspaceId === undefined - ? (wsId: number) => { - setForm(prev => ({ ...prev, spawnedWorkspaceId: wsId })) - openWorkspaceTab(wsId) - } - : undefined, + workspaceId: uploadWorkspaceId, + workspaceName: selectedWorkspace?.name ?? newWorkspaceName, + scriptUrl: selectedScript?.scriptUrl, + scriptName: selectedScript?.scriptName, }, (state) => { - // Capture the workspace id as soon as it is known so subsequent retries - // within this dialog session reuse the same workspace. - if (state.workspaceId !== undefined) { - setForm(prev => ({ ...prev, spawnedWorkspaceId: state.workspaceId })) - } if (state.phase === 'error' && state.error?.includes('sign in again')) { setForm((prev) => ({ ...prev, step: 'upload', uploadMessage: '' })) onAuthRequired?.() return } + // The workspace only exists once `finalize` succeeds — open its tab then, not earlier. + if (state.phase === 'done' && state.workspaceId !== undefined) { + setForm(prev => ({ ...prev, spawnedWorkspaceId: state.workspaceId })) + openWorkspaceTab(state.workspaceId) + } setForm((prev) => ({ ...prev, uploadMessage: state.phase === 'error' ? (state.error ?? state.message) : state.message, @@ -404,7 +403,7 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data Your files has been successfully uploaded to Open Source Brain. - You can close this dialog. + {uploadMessage || 'You can close this dialog.'} )} diff --git a/applications/idp-arc/frontend/src/core/dandiEtag.ts b/applications/idp-arc/frontend/src/core/dandiEtag.ts new file mode 100644 index 0000000..a081825 --- /dev/null +++ b/applications/idp-arc/frontend/src/core/dandiEtag.ts @@ -0,0 +1,56 @@ +import SparkMD5 from 'spark-md5' + +/** + * Part size DANDI splits uploads into. Must match theirs exactly or the ETag won't agree. + * + * DANDI validates AssetBlob.etag against `^[0-9a-f]{32}-\d{1,5}$`, so the multipart form is + * required even for a single small part — it can't be skipped below the part size. + */ +export const DANDI_ETAG_PART_SIZE = 64 * 1024 * 1024 // 64 MiB + +export interface PartPlan { + partNumber: number + start: number + end: number + size: number +} + +/** Splits a file size into DANDI's fixed-size parts. Always returns at least one part, even for a 0-byte file. */ +export function planParts(fileSize: number, partSize = DANDI_ETAG_PART_SIZE): PartPlan[] { + const parts: PartPlan[] = [] + let start = 0 + let partNumber = 1 + while (start < fileSize) { + const end = Math.min(start + partSize, fileSize) + parts.push({ partNumber, start, end, size: end - start }) + start = end + partNumber += 1 + } + if (parts.length === 0) parts.push({ partNumber: 1, start: 0, end: 0, size: 0 }) + return parts +} + +/** + * Computes the dandi-etag: MD5 of each part, concatenate the raw (binary) digests, MD5 that + * concatenation, hex-encode, append `-`. Standard S3 multipart ETag algorithm. + */ +export async function computeDandiEtag( + file: File, + partSize = DANDI_ETAG_PART_SIZE, +): Promise<{ etag: string; parts: PartPlan[] }> { + const parts = planParts(file.size, partSize) + let concatenatedRawDigests = '' + + for (const part of parts) { + const buf = await file.slice(part.start, part.end).arrayBuffer() + const hasher = new SparkMD5.ArrayBuffer() + hasher.append(buf) + concatenatedRawDigests += hasher.end(true) // raw binary string — 16 bytes per part + } + + const outer = new SparkMD5() + outer.appendBinary(concatenatedRawDigests) + const etag = `${outer.end()}-${parts.length}` + + return { etag, parts } +} diff --git a/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts b/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts new file mode 100644 index 0000000..77ab635 --- /dev/null +++ b/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts @@ -0,0 +1,56 @@ +/** Abstraction over the OSB endpoints that broker the DANDI upload. */ + +export interface UploadPart { + partNumber: number + url: string +} + +export interface UploadInitResult { + /** Absent when the content was already in DANDI — nothing was uploaded. */ + uploadId?: string + path: string + /** Empty when deduplicated; the client then skips the S3 upload entirely. */ + parts: UploadPart[] + /** Set only on the deduplicated path — the existing blob to attach a new asset to. */ + blobId?: string +} + +export interface UploadedPart { + partNumber: number + size: number + etag: string +} + +export interface UploadFinalizeResult { + assetPath: string + dandisetUrl: string + workspaceId: number + /** Everything the protocol script printed, or why it didn't run. */ + scriptOutput?: string +} + +export interface FinalizeUploadInput { + /** Absent on the deduplicated path — nothing was uploaded, so there is nothing to complete. */ + uploadId?: string + path: string + parts: UploadedPart[] + /** Existing workspace to attach to; omit to have the backend create one. */ + workspaceId?: number + workspaceName?: string + blobId?: string + /** Analysis script to run. The backend fetches it, so it must be reachable from OSB's + * cluster — not a local address. */ + scriptUrl?: string + /** Filename the script should land under in the workspace. */ + scriptName?: string +} + +export interface IDandiApi { + initUpload(token: string, taskId: string, filename: string, size: number, dandiEtag: string): Promise + + /** PUTs one part straight to S3 via its presigned URL. Returns S3's ETag for the part, + * read from the response header — requires the bucket to expose it via CORS. */ + putPart(url: string, blob: Blob): Promise + + finalizeUpload(token: string, input: FinalizeUploadInput): Promise +} diff --git a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts b/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts deleted file mode 100644 index f1776b6..0000000 --- a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * IJupyterApi — abstraction over the JupyterHub / JupyterLab interaction. - * - * DIP rule: use-cases never import the browser's `fetch`, `document.cookie`, or - * `window.open` directly. All platform side-effects go here so they can be - * swapped or faked in unit tests. - */ -export interface IJupyterApi { - /** - * Sets the session cookie required by JupyterHub and fires the spawn trigger. - * @param token Current access token. - * @param userId Subject claim from the token (used in hub URL). - * @param serverName Named-server identifier (e.g. `"42lab"`). - * @param workspaceId Numeric workspace ID (e.g. `"42"`) — the JupyterHub - * spawner hook reads this from the `workspaceId` cookie to - * mount the correct PVC. Distinct from `serverName` which - * includes the appname suffix. - */ - triggerSpawn(token: string, userId: string, serverName: string, workspaceId: string): Promise - - /** - * Sets the session cookie required by JupyterHub and fires the spawn trigger. - * deadline is exceeded. - * @returns `true` if the server is ready, `false` if timed-out or aborted. - */ - waitUntilReady( - userId: string, - serverName: string, - deadlineMs: number, - abortRef: { current: boolean }, - ): Promise - - /** - * Uploads a file using the JupyterLab Contents API (PUT, base64-encoded). - * @param token Keycloak access token forwarded as Authorization: Bearer for nginx auth. - */ - uploadFile(token: string, userId: string, serverName: string, file: File): Promise -} diff --git a/applications/idp-arc/frontend/src/core/ports/index.ts b/applications/idp-arc/frontend/src/core/ports/index.ts index 5742929..a2e9fe2 100644 --- a/applications/idp-arc/frontend/src/core/ports/index.ts +++ b/applications/idp-arc/frontend/src/core/ports/index.ts @@ -1,3 +1,3 @@ export type { IAuthClient } from './IAuthClient' export type { IWorkspaceApi } from './IWorkspaceApi' -export type { IJupyterApi } from './IJupyterApi' +export type { IDandiApi } from './IDandiApi' diff --git a/applications/idp-arc/frontend/src/core/types.ts b/applications/idp-arc/frontend/src/core/types.ts index 1967e55..1513a1e 100644 --- a/applications/idp-arc/frontend/src/core/types.ts +++ b/applications/idp-arc/frontend/src/core/types.ts @@ -23,10 +23,10 @@ export interface Workspace { export type UploadPhase = | 'idle' - | 'creating' - | 'spawning' - | 'waiting' + | 'hashing' + | 'initializing' | 'uploading' + | 'registering' | 'done' | 'error' @@ -35,15 +35,18 @@ export interface UploadState { message: string error?: string workspaceId?: number + scriptOutput?: string } -/** Human-readable labels for each phase, used both by use-cases and the UI. */ +/** Human-readable labels for each upload phase, used by both the use-case and the UI. */ export const PHASE_LABELS: Record = { idle: '', - creating: '1 / 4 — Creating workspace…', - spawning: '2 / 4 — Starting JupyterLab server…', - waiting: '3 / 4 — Waiting for JupyterLab to be ready…', - uploading: '4 / 4 — Uploading file…', + hashing: '1 / 4 — Computing checksum…', + initializing: '2 / 4 — Preparing upload…', + uploading: '3 / 4 — Uploading file…', + // finalize also spawns the workspace and runs the protocol script synchronously — hence "can + // take a few minutes" (see jupyter_kernel_client.py in OSBv2's workspaces app). + registering: '4 / 4 — Registering asset, creating workspace & running script (can take a few minutes)…', done: 'Done!', error: 'Error', } diff --git a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts new file mode 100644 index 0000000..a4c7afb --- /dev/null +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts @@ -0,0 +1,105 @@ +import type { IAuthClient } from '../ports/IAuthClient' +import type { IDandiApi } from '../ports/IDandiApi' +import type { UploadState } from '../types' +import { PHASE_LABELS } from '../types' +import { computeDandiEtag } from '../dandiEtag' + +export type OnProgress = (state: UploadState) => void + +export interface CreateAndUploadToDandiInput { + taskId: string + file: File + /** Existing workspace to attach the asset to; omit to create a new one. */ + workspaceId?: number + workspaceName?: string + /** The selected protocol's analysis script, resolved from protocols.json by the caller. + * Delivered into the workspace alongside the data so it is ready to run. */ + scriptUrl?: string + scriptName?: string +} + +/** + * Uploads a file to DANDI and attaches it to an OSB workspace. + * + * 1. Hash the file in the browser into a dandi-etag — the content digest DANDI identifies a + * blob by. It has to be sent up front, before any bytes move, because DANDI answers with + * "already have this" and skips the upload entirely when the digest matches an existing blob. + * 2. POST /dandi/upload/init — the backend calls DANDI with the admin key and passes back one + * presigned S3 URL per part. Each URL carries its own signature and expiry, which is what + * lets the browser write to a bucket it has no credentials for. + * 3. PUT each part's bytes to its presigned URL — a plain unauthenticated PUT, browser straight + * to S3. The file never passes through OSB, so size costs the server nothing. + * 4. POST /dandi/upload/finalize — registers the asset, attaches the workspace, runs the script + */ +export function createCreateAndUploadToDandiUseCase( + auth: Pick, + dandiApi: IDandiApi, +) { + return async function createAndUploadToDandi( + input: CreateAndUploadToDandiInput, + onProgress: OnProgress, + abortRef: { current: boolean }, + ): Promise { + const { taskId, file, workspaceId, workspaceName, scriptUrl, scriptName } = input + + try { + // ── Step 1: hash the file into DANDI's content digest ───────────────── + onProgress({ phase: 'hashing', message: PHASE_LABELS.hashing }) + const { etag, parts: partPlan } = await computeDandiEtag(file) + if (abortRef.current) return null + + // ── Step 2: initialize ──────────────────────────────────────────────── + onProgress({ phase: 'initializing', message: PHASE_LABELS.initializing }) + const initToken = await auth.getToken(30) + const init = await dandiApi.initUpload(initToken, taskId, file.name, file.size, etag) + if (abortRef.current) return null + + // ── Step 3: PUT each part to its presigned URL ──────────────────────── + // init returns no parts when DANDI already has this exact content — just a blob_id to + // attach a new asset to, so there is nothing to upload. + const uploadedParts = [] + if (init.parts.length > 0) { + onProgress({ phase: 'uploading', message: PHASE_LABELS.uploading }) + for (const part of init.parts) { + if (abortRef.current) return null + const plan = partPlan.find((p) => p.partNumber === part.partNumber) + if (!plan) throw new Error(`No local part plan for part ${part.partNumber}`) + const blob = file.slice(plan.start, plan.end) + const s3Etag = await dandiApi.putPart(part.url, blob) + uploadedParts.push({ partNumber: part.partNumber, size: plan.size, etag: s3Etag }) + } + } else { + onProgress({ phase: 'uploading', message: 'Already in DANDI — skipping upload…' }) + } + if (abortRef.current) return null + + // finalize also spawns the workspace and runs the script, so it can block for minutes. + // The token is used at the very end of that too, so it must outlive the whole call — + // hence 600s rather than the usual short floor. + onProgress({ phase: 'registering', message: PHASE_LABELS.registering }) + const finalizeToken = await auth.getToken(600) + const result = await dandiApi.finalizeUpload(finalizeToken, { + uploadId: init.uploadId, + path: init.path, + parts: uploadedParts, + workspaceId, + workspaceName, + blobId: init.blobId, + scriptUrl, + scriptName, + }) + + onProgress({ + phase: 'done', + message: PHASE_LABELS.done, + workspaceId: result.workspaceId, + scriptOutput: result.scriptOutput, + }) + return result.workspaceId + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + onProgress({ phase: 'error', message: PHASE_LABELS.error, error: msg }) + return null + } + } +} diff --git a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts deleted file mode 100644 index 6d66b97..0000000 --- a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { IAuthClient } from '../ports/IAuthClient' -import type { IWorkspaceApi } from '../ports/IWorkspaceApi' -import type { IJupyterApi } from '../ports/IJupyterApi' -import type { UploadState } from '../types' -import { PHASE_LABELS } from '../types' - -/** Callback the use-case calls at each phase transition; the UI uses it to drive React state. */ -export type OnProgress = (state: UploadState) => void - -export interface CreateAndUploadInput { - workspaceName: string - file: File - /** Subject claim (`sub`) from the token payload — identifies the JupyterHub user. */ - userId: string - /** When provided the workspace creation step is skipped and the file is uploaded to this workspace. */ - workspaceId?: number - /** - * Called synchronously the moment the workspace id is known (right after - * Step 1, before the long PVC wait). Use this to open the workspace tab - * while still inside the user-gesture async chain so the browser allows it. - */ - onWorkspaceCreated?: (wsId: number) => void -} - -/** - * createAndUploadWorkspace use-case - * - * Owns the *sequence* and *error handling* of the 4-step workflow: - * 1. Create workspace via REST API - * 2. Trigger JupyterHub spawn - * 3. Poll until JupyterLab is ready - * 4. Upload file via JupyterLab Contents API - * - * DI: every side-effect (auth, HTTP, browser APIs) is injected through ports. - * Testing: pass plain fake objects — no vi.mock() required. - * - * @example - * const run = createCreateAndUploadUseCase(auth, workspaceApi, jupyterApi) - * await run({ workspaceName: 'My WS', file, userId }, setUploadState, abortRef) - */ -export function createCreateAndUploadUseCase( - auth: Pick, - workspaceApi: Pick, - jupyterApi: Pick, - // JupyterHub named-server suffix: the subdomain appname of the JupyterHub - // deployment. For lab.v2dev.opensourcebrain.org the server name is - // "{workspaceId}lab" — i.e. the suffix is "lab". - serverSuffix = '', -) { - return async function createAndUpload( - input: CreateAndUploadInput, - onProgress: OnProgress, - abortRef: { current: boolean }, - ): Promise { - const { workspaceName, file, userId, onWorkspaceCreated } = input - - try { - const token = await auth.getToken(30) - - // ── Step 1: Create workspace (skipped when uploading to an existing one) ─ - let wsId: number - if (input.workspaceId) { - wsId = input.workspaceId - } else { - onProgress({ phase: 'creating', message: PHASE_LABELS.creating }) - wsId = await workspaceApi.createWorkspace(token, workspaceName) - onWorkspaceCreated?.(wsId) - } - - if (abortRef.current) return null - - // ── Step 2: Trigger JupyterHub spawn ────────────────────────────────── - const serverName = `${wsId}${serverSuffix}` - onProgress({ phase: 'spawning', message: PHASE_LABELS.spawning, workspaceId: wsId }) - - const spawnToken = await auth.getToken(30) - await jupyterApi.triggerSpawn(spawnToken, userId, serverName, `${wsId}`) - - // Wait 30 s for the PVC to initialise before polling - for (let i = 30; i > 0; i--) { - if (abortRef.current) return null - onProgress({ - phase: 'spawning', - message: `2 / 4 — Waiting for PVC to initialise… ${i}s`, - workspaceId: wsId, - }) - await sleep(1_000) - } - - if (abortRef.current) return null - - // ── Step 3: Poll until JupyterLab is ready and per-server session is established ── - onProgress({ phase: 'waiting', message: PHASE_LABELS.waiting, workspaceId: wsId }) - const deadline = Date.now() + 240_000 - const ready = await jupyterApi.waitUntilReady(userId, serverName, deadline, abortRef) - if (!ready) { throw new Error('Could not reach JupyterLab within the timeout.') } - - // ── Step 4: Upload file ─────────────────────────────────────────────── - onProgress({ phase: 'uploading', message: PHASE_LABELS.uploading, workspaceId: wsId }) - const uploadToken = await auth.getToken(30) - await jupyterApi.uploadFile(uploadToken, userId, serverName, file) - - onProgress({ phase: 'done', message: PHASE_LABELS.done, workspaceId: wsId }) - return wsId - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err) - onProgress({ phase: 'error', message: PHASE_LABELS.error, error: msg }) - return null - } - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/applications/idp-arc/frontend/src/core/use-cases/createWorkspace.ts b/applications/idp-arc/frontend/src/core/use-cases/createWorkspace.ts new file mode 100644 index 0000000..29af84b --- /dev/null +++ b/applications/idp-arc/frontend/src/core/use-cases/createWorkspace.ts @@ -0,0 +1,13 @@ +import type { IAuthClient } from '../ports/IAuthClient' +import type { IWorkspaceApi } from '../ports/IWorkspaceApi' + +/** Creates a new, empty workspace with the given name and returns its id. */ +export function createCreateWorkspaceUseCase( + auth: Pick, + workspaceApi: Pick, +) { + return async function createWorkspace(name: string): Promise { + const token = await auth.getToken(30) + return workspaceApi.createWorkspace(token, name) + } +} diff --git a/applications/idp-arc/frontend/src/core/use-cases/index.ts b/applications/idp-arc/frontend/src/core/use-cases/index.ts index 4a6b430..46d82b6 100644 --- a/applications/idp-arc/frontend/src/core/use-cases/index.ts +++ b/applications/idp-arc/frontend/src/core/use-cases/index.ts @@ -1,3 +1 @@ export { createLoadWorkspacesUseCase } from './loadWorkspaces' -export { createCreateAndUploadUseCase } from './createAndUploadWorkspace' -export type { OnProgress, CreateAndUploadInput } from './createAndUploadWorkspace' diff --git a/applications/idp-arc/frontend/src/data/protocols.json b/applications/idp-arc/frontend/src/data/protocols.json index a5b72bb..c9262d0 100644 --- a/applications/idp-arc/frontend/src/data/protocols.json +++ b/applications/idp-arc/frontend/src/data/protocols.json @@ -2,31 +2,55 @@ { "name": "Two arm bandit task", "desc": "Test the flexibility using MED", - "description": "The two-armed bandit task is a classic paradigm in behavioral neuroscience used to study decision-making under uncertainty. In this task, participants are presented with two options, each associated with a different probability of reward. By repeatedly choosing between the two options, participants learn to exploit the option with the higher reward probability while also exploring the other option to ensure that they are not missing out on a potentially better source of reward. This task has been used to investigate the neural mechanisms underlying reinforcement learning, exploration-exploitation trade-offs, and the role of different brain regions in decision-making." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "two-arm-bandit-analysis.py", + "description": "The two-armed bandit task is a classic paradigm in behavioral neuroscience used to study decision-making under uncertainty. In this task, participants are presented with two options, each associated with a different probability of reward. By repeatedly choosing between the two options, participants learn to exploit the option with the higher reward probability while also exploring the other option to ensure that they are not missing out on a potentially better source of reward. This task has been used to investigate the neural mechanisms underlying reinforcement learning, exploration-exploitation trade-offs, and the role of different brain regions in decision-making.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" }, { "name": "ASST digging task", "desc": "Attentional set-shifting task", - "description": "The attentional set-shifting task (ASST) is a rodent analogue of the Cambridge Neuropsychological Test Automated Battery (CANTAB) IED task. It assesses the ability to shift attention between perceptual dimensions of compound stimuli. The task requires animals to learn sequential discriminations, measuring the cost of shifting attention from one perceptual dimension to another." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "asst-digging-analysis.py", + "description": "The attentional set-shifting task (ASST) is a rodent analogue of the Cambridge Neuropsychological Test Automated Battery (CANTAB) IED task. It assesses the ability to shift attention between perceptual dimensions of compound stimuli. The task requires animals to learn sequential discriminations, measuring the cost of shifting attention from one perceptual dimension to another.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" }, { "name": "Four-choice reversal digging task", "desc": "Reversal learning assessment", - "description": "The four-choice reversal digging task expands on the two-armed paradigm by introducing four distinct odor-digging options. Animals must identify the rewarded option and adapt when contingencies reverse. This task is particularly sensitive to orbitofrontal cortex dysfunction and provides multiple reversal learning indices." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "four-choice-reversal-analysis.py", + "description": "The four-choice reversal digging task expands on the two-armed paradigm by introducing four distinct odor-digging options. Animals must identify the rewarded option and adapt when contingencies reverse. This task is particularly sensitive to orbitofrontal cortex dysfunction and provides multiple reversal learning indices.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" }, { "name": "Open field task", "desc": "Locomotion and anxiety assessment", - "description": "The open field task is a widely used behavioral assay for measuring locomotion, anxiety-like behavior, and exploratory activity in rodents. Animals are placed in a novel arena and their movement patterns, time spent in the center versus periphery, and rearing behavior are recorded and analyzed." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "open-field-analysis.py", + "description": "The open field task is a widely used behavioral assay for measuring locomotion, anxiety-like behavior, and exploratory activity in rodents. Animals are placed in a novel arena and their movement patterns, time spent in the center versus periphery, and rearing behavior are recorded and analyzed.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" }, { "name": "Elevated plus maze", "desc": "Anxiety and risk-taking behavior", - "description": "The elevated plus maze (EPM) is a standard test for anxiety-like behavior in rodents. The maze consists of two open and two enclosed arms elevated above the floor. Anxious animals spend more time in the enclosed arms, while exploratory animals venture into the open arms. This task is sensitive to anxiolytic and anxiogenic compounds." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "elevated-plus-maze-analysis.py", + "description": "The elevated plus maze (EPM) is a standard test for anxiety-like behavior in rodents. The maze consists of two open and two enclosed arms elevated above the floor. Anxious animals spend more time in the enclosed arms, while exploratory animals venture into the open arms. This task is sensitive to anxiolytic and anxiogenic compounds.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" }, { "name": "Foraging task", "desc": "Patch-leaving and optimal foraging", - "description": "The foraging task models naturalistic patch-leaving decisions based on optimal foraging theory. Animals must decide when to leave a depleting food patch and travel to a new one, balancing exploitation of current resources against exploration of potentially richer alternatives. This task probes cost–benefit decision-making circuits." + "scriptUrl": "https://gist.githubusercontent.com/D-GopalKrishna/fa759c74fce007dc3cc0808382e8aa79/raw/gistfile1.txt", + "scriptName": "foraging-analysis.py", + "description": "The foraging task models naturalistic patch-leaving decisions based on optimal foraging theory. Animals must decide when to leave a depleting food patch and travel to a new one, balancing exploitation of current resources against exploration of potentially richer alternatives. This task probes cost\u2013benefit decision-making circuits.", + "imageUrl": "/protocol1.png", + "videoUrl": "https://static.vecteezy.com/system/resources/previews/013/566/514/mp4/futuristic-3d-hologram-brain-made-of-glowing-connections-concept-of-artificial-intelligence-computer-intelligent-learning-links-circuits-and-network-data-unfocused-luminous-particles-spinning-video.mp4" } ] diff --git a/applications/idp-arc/frontend/src/infra/dandiApiClient.ts b/applications/idp-arc/frontend/src/infra/dandiApiClient.ts new file mode 100644 index 0000000..ef0e4f8 --- /dev/null +++ b/applications/idp-arc/frontend/src/infra/dandiApiClient.ts @@ -0,0 +1,80 @@ +import type { FinalizeUploadInput, IDandiApi, UploadFinalizeResult, UploadInitResult } from '../core/ports/IDandiApi' + +/** + * DandiApiClient — talks to idp-arc's OWN backend (same origin, `/api/dandi/...`), which is + * what actually holds the EMBER-DANDI admin key and calls DANDI/OSB on the browser's behalf. + */ +export class DandiApiClient implements IDandiApi { + constructor(private readonly baseApiUrl: string) {} + + async initUpload(token: string, taskId: string, filename: string, size: number, dandiEtag: string): Promise { + const res = await fetch(`${this.baseApiUrl}/dandi/upload/init`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId, filename, size, dandi_etag: dandiEtag }), + }) + if (!res.ok) { + throw new Error(`Upload init failed: ${res.status} ${res.statusText} — ${await res.text().catch(() => '')}`) + } + const data = await res.json() as { + upload_id?: string + path: string + parts: { part_number: number; url: string }[] + blob_id?: string + } + return { + uploadId: data.upload_id, + path: data.path, + blobId: data.blob_id, + parts: (data.parts ?? []).map((p) => ({ partNumber: p.part_number, url: p.url })), + } + } + + async putPart(url: string, blob: Blob): Promise { + const res = await fetch(url, { method: 'PUT', body: blob }) + if (!res.ok) { + throw new Error(`Part upload to S3 failed: ${res.status} ${res.statusText}`) + } + const etag = res.headers.get('ETag') + if (!etag) { + throw new Error( + 'S3 did not expose an ETag header on the part upload response — the bucket needs ' + + 'Access-Control-Expose-Headers: ETag in its CORS config.', + ) + } + return etag.replaceAll('"', '') + } + + async finalizeUpload(token: string, input: FinalizeUploadInput): Promise { + const { uploadId, path, parts, workspaceId, workspaceName, blobId, scriptUrl, scriptName } = input + const res = await fetch(`${this.baseApiUrl}/dandi/upload/finalize`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path, + ...(uploadId ? { upload_id: uploadId } : {}), + ...(blobId ? { blob_id: blobId } : {}), + parts: parts.map((p) => ({ part_number: p.partNumber, size: p.size, etag: p.etag })), + ...(workspaceId !== undefined ? { workspace_id: workspaceId } : {}), + ...(workspaceName ? { workspace_name: workspaceName } : {}), + ...(scriptUrl ? { script_url: scriptUrl } : {}), + ...(scriptName ? { script_name: scriptName } : {}), + }), + }) + if (!res.ok) { + throw new Error(`Upload finalize failed: ${res.status} ${res.statusText} — ${await res.text().catch(() => '')}`) + } + const data = await res.json() as { + asset_path: string + dandiset_url: string + workspace_id: number + script_output?: string + } + return { + assetPath: data.asset_path, + dandisetUrl: data.dandiset_url, + workspaceId: data.workspace_id, + scriptOutput: data.script_output, + } + } +} diff --git a/applications/idp-arc/frontend/src/infra/index.ts b/applications/idp-arc/frontend/src/infra/index.ts index 73659e9..ccacbfc 100644 --- a/applications/idp-arc/frontend/src/infra/index.ts +++ b/applications/idp-arc/frontend/src/infra/index.ts @@ -1,3 +1,2 @@ export { KeycloakAuthClient } from './keycloakAuthClient' export { WorkspaceApiClient } from './workspaceApiClient' -export { JupyterApiClient } from './jupyterApiClient' diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts deleted file mode 100644 index 8262f8f..0000000 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { IJupyterApi } from '../core/ports/IJupyterApi' - -/** - * JupyterApiClient — concrete IJupyterApi implementation. - * - * Owns ALL browser side-effects related to JupyterHub/JupyterLab: - * - setting the auth cookie - * - fire-and-forget spawn request - * - polling the Contents API - * - file upload via the Contents API - * - opening a browser tab - * - * Use-cases only see IJupyterApi — they have no idea these browser APIs exist. - */ -export class JupyterApiClient implements IJupyterApi { - // Captured from the first successful contents probe; Nginx echoes the _xsrf - // cookie value as X-XSRF-Token so we can read it even when the cookie path - // makes it inaccessible via document.cookie. - private xsrfToken = '' - - constructor( - private readonly jupyterBase: string, // e.g. "/jupyter-proxy" - private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" - ) {} - - async triggerSpawn(token: string, userId: string, serverName: string, workspaceId: string): Promise { - // Production: set domain cookies so the browser sends them to lab.domain. - document.cookie = `accessToken=${token};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` - document.cookie = `workspaceId=${workspaceId};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` - // Dev (localhost): set the same cookies without a domain restriction so the - // browser sends them to localhost:5173, and the Vite proxy forwards them to - // lab.v2dev.opensourcebrain.org in the Cookie header. - // kc-access is the cookie name chkclogin checks first. - document.cookie = `kc-access=${token};path=/;SameSite=Lax` - document.cookie = `accessToken=${token};path=/;SameSite=Lax` - document.cookie = `workspaceId=${workspaceId};path=/;SameSite=Lax` - - // Step 1: chkclogin — the accessToken URL param lets nginx inject it as a - // Cookie even before the browser has stored it. Sets the hub session cookie. - await fetch( - `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, - { credentials: 'include', redirect: 'manual' }, - ).catch(() => {}) - - // Step 2: Spawn — NO accessToken URL param here. If it were present, nginx's - // map $arg_accessToken $proxy_cookie would replace ALL browser cookies with - // just "accessToken=…", dropping workspaceId. Without it, nginx forwards all - // browser cookies (including workspaceId) so the spawner hook can mount the - // correct workspace PVC. - await fetch( - `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, - { credentials: 'include', redirect: 'manual' }, - ).catch(() => {}) - } - - async waitUntilReady( - userId: string, - serverName: string, - deadlineMs: number, - abortRef: { current: boolean }, - ): Promise { - const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/` - const labUrl = `${this.jupyterBase}/user/${userId}/${serverName}/lab` - - while (!abortRef.current && Date.now() < deadlineMs) { - try { - // First probe /lab via fetch to see its actual status (before iframe). - // JupyterHub serves a 200 "spawning" page while the server starts, and - // only 302→OAuth when the server is running but lacks a per-server cookie. - const labProbe = await fetch(labUrl, { - credentials: 'include', - redirect: 'manual', - }) - const labStatus = labProbe.status - const labCT = labProbe.headers.get('content-type') ?? '' - console.log('[waitUntilReady] /lab status:', labStatus, 'content-type:', labCT) - - if (labStatus === 0 || labStatus === 302 || labStatus === 303) { - // Opaque redirect — JupyterLab is running but needs per-server OAuth. - // Load in hidden iframe so each redirect stores its cookies properly. - console.log('[waitUntilReady] /lab needs OAuth dance, loading in iframe…') - await this.loadInHiddenFrame(labUrl) - // Give the browser a moment to flush the Set-Cookie from the callback. - await sleep(1_000) - } else if (labStatus === 200 && labCT.includes('text/html')) { - // Check if this is the spawn-pending page or actual JupyterLab. - const body = await labProbe.text() - const isSpawning = body.includes('spawn') || body.includes('Spawning') || !body.includes('JupyterLab') - console.log('[waitUntilReady] /lab 200 HTML, isSpawning:', isSpawning) - if (isSpawning) { - // Still starting — probe contents will fail; just wait. - await sleep(4_000) - continue - } - // JupyterLab is up. The iframe-based OAuth may have already run in a - // prior iteration. Now probe contents directly. - } - - const probe = await fetch(contentsUrl, { - credentials: 'include', - redirect: 'follow', - // jupyter_server ≥2 rejects cookie-authenticated API requests that lack - // a matching _xsrf token — even GETs (cross-site hardening) — with a - // bare 403 "Forbidden" (the real reason is logged server-side, not in - // the body). Send the user-server _xsrf (readable at Path=/ after the - // proxy cookie-path fix) as X-XSRFToken, same as the upload PUT does. - headers: { 'X-XSRFToken': this.xsrfToken || getXsrfToken() }, - }) - - console.log('[waitUntilReady] /api/contents/ status:', probe.status, - 'cookies visible:', document.cookie.split(';').map(c => c.trim().split('=')[0]).join(',')) - if (probe.ok) { - if ((probe.headers.get('content-type') ?? '').includes('application/json')) { - const xsrf = probe.headers.get('X-XSRF-Token') ?? getXsrfToken() - if (xsrf) this.xsrfToken = xsrf - return true - } - // HTML response = spawn-pending page, keep polling. - } else if (probe.status === 403) { - // Log WHY it's 403 — the body disambiguates XSRF ("'_xsrf' argument - // missing" / "XSRF cookie does not match") from an origin/scope reject. - const body403 = await probe.text().catch(() => '') - console.log('[waitUntilReady] /api/contents/ 403 body:', body403.slice(0, 300)) - console.log('[waitUntilReady] _xsrf cookie:', getXsrfToken().slice(0, 12), - '| X-XSRF-Token hdr:', probe.headers.get('X-XSRF-Token')) - await this.loadInHiddenFrame(labUrl) - await sleep(1_000) - } else if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) { - break - } - } catch (e) { - console.log('[waitUntilReady] error:', e) - } - await sleep(4_000) - } - - return false - } - - async uploadFile(token: string, userId: string, serverName: string, file: File): Promise { - const labUrl = `${this.jupyterBase}/user/${userId}/${serverName}/lab` - const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/${encodeURIComponent(file.name)}` - const content = await readFileAsBase64(file) - const uploadDeadline = Date.now() + 300_000 // 5 minutes to land a successful PUT - - while (Date.now() < uploadDeadline) { - // Ensure the per-server session cookie is present before attempting the - // PUT. Use a hidden iframe instead of fetch(redirect:'follow') — fetch - // does not store cookies from intermediate redirect responses before - // firing the next request in the chain, causing an infinite OAuth loop. - await this.loadInHiddenFrame(labUrl) - - const xsrf = this.xsrfToken || getXsrfToken() - console.log('[uploadFile] xsrf:', xsrf ? `"${xsrf.slice(0, 10)}…"` : '(empty)') - console.log('[uploadFile] content length (chars):', content.length) - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 120_000) - - try { - const res = await fetch(contentsUrl, { - method: 'PUT', - credentials: 'include', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - 'X-XSRFToken': xsrf, - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - name: file.name, - path: file.name, - type: 'file', - format: 'base64', - content, - }), - }) - console.log('[uploadFile] response:', res.status, res.statusText) - if (res.ok) return - - if (res.status === 405 || res.status === 502 || res.status === 503) { - await sleep(10_000) - continue - } - - const body = await res.text().catch(() => '') - throw new Error(`Upload failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - throw new Error('Upload timed out after 2 minutes — the JupyterLab server did not respond.') - } - throw err - } finally { - clearTimeout(timeoutId) - } - } - - throw new Error( - 'JupyterLab did not become ready for upload within 5 minutes. ' + - 'The workspace was created — you can open it and upload the file manually.', - ) - } - - /** - * Load a URL in a zero-size hidden iframe and resolve when the frame fires - * its load or error event (or after timeoutMs, whichever comes first). - * - * This is used to complete the JupyterHub per-server OAuth dance. Unlike - * fetch(redirect:'follow'), iframe navigation stores Set-Cookie headers from - * each intermediate redirect response before making the next request, so the - * per-server session cookie set by /oauth_callback is available when the - * browser follows the final redirect to /lab. - */ - private loadInHiddenFrame(url: string, timeoutMs = 8_000): Promise { - return new Promise((resolve) => { - const frame = document.createElement('iframe') - frame.style.cssText = - 'position:fixed;left:-9999px;top:-9999px;width:0;height:0;border:0;opacity:0;pointer-events:none;' - let settled = false - const finish = () => { - if (settled) return - settled = true - clearTimeout(timer) - frame.remove() - resolve() - } - const timer = setTimeout(finish, timeoutMs) - frame.addEventListener('load', finish) - frame.addEventListener('error', finish) - document.body.appendChild(frame) - frame.src = url - }) - } -} - -function getXsrfToken(): string { - const match = document.cookie.match(/(?:^|;)\s*_xsrf=([^;]+)/) - return match ? decodeURIComponent(match[1]) : '' -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function readFileAsBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = () => resolve((reader.result as string).split(',')[1]) - reader.onerror = reject - }) -} diff --git a/applications/idp-arc/frontend/src/infra/keycloakAuthClient.ts b/applications/idp-arc/frontend/src/infra/keycloakAuthClient.ts index 650c793..99a3b27 100644 --- a/applications/idp-arc/frontend/src/infra/keycloakAuthClient.ts +++ b/applications/idp-arc/frontend/src/infra/keycloakAuthClient.ts @@ -17,6 +17,13 @@ export class KeycloakAuthClient implements IAuthClient { init(): Promise { return this.kc.init({ onLoad: 'check-sso', + // Without this, check-sso does its login-status check via a full top-window + // redirect to the Keycloak server and back — if that server is unreachable, + // the whole app is replaced by the dead auth server instead of just failing + // to log in. Routing the check through a hidden iframe (this static page, + // which was already shipped in public/ but never wired up) keeps the app + // on-screen even when Keycloak is down. + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', pkceMethod: 'S256', checkLoginIframe: false, scope: 'openid profile email administrator-scope', diff --git a/applications/idp-arc/frontend/src/infra/mocks/index.ts b/applications/idp-arc/frontend/src/infra/mocks/index.ts index add2150..898815b 100644 --- a/applications/idp-arc/frontend/src/infra/mocks/index.ts +++ b/applications/idp-arc/frontend/src/infra/mocks/index.ts @@ -1,3 +1,2 @@ export { MockAuthClient } from './mockAuthClient' export { MockWorkspaceApiClient } from './mockWorkspaceApiClient' -export { MockJupyterApiClient } from './mockJupyterApiClient' diff --git a/applications/idp-arc/frontend/src/infra/mocks/mockDandiApiClient.ts b/applications/idp-arc/frontend/src/infra/mocks/mockDandiApiClient.ts new file mode 100644 index 0000000..112aaee --- /dev/null +++ b/applications/idp-arc/frontend/src/infra/mocks/mockDandiApiClient.ts @@ -0,0 +1,44 @@ +import type { FinalizeUploadInput, IDandiApi, UploadFinalizeResult, UploadInitResult } from '../../core/ports/IDandiApi' + +let nextWorkspaceId = 900 +let nextUploadId = 1 + +/** MockDandiApiClient — in-memory stub for IDandiApi. No real DANDI/OSB calls. */ +export class MockDandiApiClient implements IDandiApi { + async initUpload(_token: string, taskId: string, filename: string, size: number, _dandiEtag: string): Promise { + await delay(400) + const uploadId = `mock-upload-${nextUploadId++}` + const path = `task-${taskId}/sub-mockuser/${filename}` + console.info(`[MockDandiApiClient] initUpload(${filename}, ${size}B) → ${uploadId} @ ${path}`) + return { uploadId, path, parts: [{ partNumber: 1, url: `blob:mock-part-url` }] } + } + + async putPart(_url: string, _blob: Blob): Promise { + await delay(500) + return 'mock-s3-etag' + } + + async finalizeUpload(_token: string, input: FinalizeUploadInput): Promise { + const { uploadId, path, workspaceId, workspaceName, scriptUrl, scriptName } = input + // The real backend blocks here for as long as spawning + running the script takes — mimic + // that shape (a longer delay) rather than resolving instantly, so this feels representative. + await delay(scriptUrl ? 1500 : 500) + const wsId = workspaceId ?? nextWorkspaceId++ + console.info( + `[MockDandiApiClient] finalizeUpload(${uploadId}) → workspace ${wsId} ` + + `(${workspaceName ?? 'existing'}), script ${scriptName ?? 'none'}`, + ) + return { + assetPath: path, + dandisetUrl: 'https://dandi.emberarchive.org/dandiset/000533/draft', + workspaceId: wsId, + scriptOutput: scriptUrl + ? 'Fetched 1818 bytes from DANDI\n\nProtocol : two-arm-bandit\nTrials : 20\nReward : 13/20 (65%)\n\nWrote ./cumulative_reward.png\n' + : undefined, + } + } +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts deleted file mode 100644 index 15f9f1c..0000000 --- a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { IJupyterApi } from '../../core/ports/IJupyterApi' - -/** - * MockJupyterApiClient — in-memory stub for IJupyterApi. - * - * • triggerSpawn() logs and returns immediately - * • waitUntilReady() simulates a 1-second "boot" then resolves true - * • uploadFile() logs and resolves immediately - */ -export class MockJupyterApiClient implements IJupyterApi { - async triggerSpawn(_token: string, userId: string, serverName: string, _workspaceId: string): Promise { - console.info( - `[MockJupyterApiClient] triggerSpawn(userId="${userId}", serverName="${serverName}")`, - ) - } - - async waitUntilReady( - userId: string, - serverName: string, - _deadlineMs: number, - abortRef: { current: boolean }, - ): Promise { - console.info( - `[MockJupyterApiClient] waitUntilReady(userId="${userId}", serverName="${serverName}") — simulating 1 s boot…`, - ) - const BOOT_MS = 1_000 - const POLL_MS = 200 - const steps = BOOT_MS / POLL_MS - - for (let i = 0; i < steps; i++) { - if (abortRef.current) { - console.info('[MockJupyterApiClient] waitUntilReady() aborted') - return false - } - await delay(POLL_MS) - } - - console.info('[MockJupyterApiClient] waitUntilReady() → ready') - return true - } - - async uploadFile(_token: string, userId: string, serverName: string, file: File): Promise { - await delay(500) - console.info( - `[MockJupyterApiClient] uploadFile(userId="${userId}", serverName="${serverName}", file="${file.name}")`, - ) - } -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} diff --git a/applications/idp-arc/frontend/src/locales/en/common.json b/applications/idp-arc/frontend/src/locales/en/common.json index 8df71b4..a9d528a 100644 --- a/applications/idp-arc/frontend/src/locales/en/common.json +++ b/applications/idp-arc/frontend/src/locales/en/common.json @@ -17,7 +17,7 @@ "title": "OSB Workspaces", "loading": "Loading workspaces…", "empty": "No workspaces found.", - "newButton": "+ New workspace & upload file", + "newButton": "+ New workspace", "table": { "id": "ID", "name": "Name", @@ -28,22 +28,12 @@ } }, "modal": { - "title": "New workspace & upload file", + "title": "New workspace", "workspaceNameLabel": "Workspace name", "workspaceNamePlaceholder": "My new workspace", - "fileLabel": "File to upload", - "fileInfo": "{{name}} ({{size}} KB)", "cancel": "Cancel", - "createAndUpload": "Create & upload", - "close": "Close", - "retry": "Retry upload", - "workspaceId": "Workspace ID: ", - "uploadSuccess": "✓ File uploaded successfully!", - "openWorkspace": "Open workspace in JupyterLab ↗", - "uploadFailed": "Upload failed", - "workspaceCreated": "The workspace was created.", - "uploadManually": "Open it in JupyterLab ↗", - "uploadManuallyTrailing": "to upload the file manually." + "create": "Create", + "creationFailed": "Failed to create workspace: {{message}}" }, "collaborators": { "sectionTitle": "Collaborators", diff --git a/applications/idp-arc/frontend/src/pages/ProtocolsPage.tsx b/applications/idp-arc/frontend/src/pages/ProtocolsPage.tsx index 7709895..5489b15 100644 --- a/applications/idp-arc/frontend/src/pages/ProtocolsPage.tsx +++ b/applications/idp-arc/frontend/src/pages/ProtocolsPage.tsx @@ -152,7 +152,7 @@ export default function ProtocolsPage() { - + @@ -176,7 +176,7 @@ export default function ProtocolsPage() { (null) const [workspacesError, setWorkspacesError] = useState(null) const [workspacesLoading, setWorkspacesLoading] = useState(false) @@ -14,9 +14,8 @@ export default function Workspaces() { // Modal state const [modalOpen, setModalOpen] = useState(false) const [workspaceName, setWorkspaceName] = useState('') - const [selectedFile, setSelectedFile] = useState(null) - const [uploadState, setUploadState] = useState({ phase: 'idle', message: '' }) - const abortRef = useRef(false) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) const loadWorkspaceList = useCallback(() => { setWorkspacesLoading(true) @@ -40,35 +39,27 @@ export default function Workspaces() { function openModal() { setWorkspaceName('') - setSelectedFile(null) - setUploadState({ phase: 'idle', message: '' }) - abortRef.current = false + setCreateError(null) setModalOpen(true) } function closeModal() { - abortRef.current = true setModalOpen(false) } - async function handleCreateAndUpload() { - if (!workspaceName.trim() || !selectedFile) return - abortRef.current = false - - await createAndUpload( - { - workspaceName: workspaceName.trim(), - file: selectedFile, - userId: tokenParsed?.sub as string, - onWorkspaceCreated: (wsId) => { - window.open(getWorkspaceUrl(wsId), '_blank') - }, - }, - setUploadState, - abortRef, - ) - - loadWorkspaceList() + async function handleCreate() { + if (!workspaceName.trim()) return + setCreating(true) + setCreateError(null) + try { + await createWorkspace(workspaceName.trim()) + setModalOpen(false) + loadWorkspaceList() + } catch (err: unknown) { + setCreateError(err instanceof Error ? err.message : String(err)) + } finally { + setCreating(false) + } } if (authState === 'loading') { @@ -89,8 +80,6 @@ export default function Workspaces() { ) } - const isRunning = ['creating', 'spawning', 'waiting', 'uploading'].includes(uploadState.phase) - return (
@@ -139,7 +128,7 @@ export default function Workspaces() { )}
- {/* ── Upload modal ──────────────────────────────────────────────────── */} + {/* ── New workspace modal ───────────────────────────────────────────── */} {modalOpen && (
{ if (e.target === e.currentTarget) closeModal() }} > -
+

{t('modal.title')}

- {uploadState.phase === 'idle' && ( - <> - - - - -
- - -
- + + + {createError && ( +

+ {t('modal.creationFailed', { message: createError })} +

)} - {isRunning && ( -
-

{uploadState.message}

- - {uploadState.workspaceId && ( -

- {t('modal.workspaceId')}{uploadState.workspaceId} -

- )} -
- -
-
- )} - - {uploadState.phase === 'done' && ( -
-

{t('modal.uploadSuccess')}

- {uploadState.workspaceId && ( -

- - {t('modal.openWorkspace')} - -

- )} -
- -
-
- )} - - {uploadState.phase === 'error' && ( -
-

{t('modal.uploadFailed')}

-

{uploadState.error}

- {uploadState.workspaceId && ( -

- {t('modal.workspaceCreated')}{' '} - - {t('modal.uploadManually')} - {' '} - {t('modal.uploadManuallyTrailing')} -

- )} -
- - -
-
- )} +
+ + +
)} diff --git a/applications/idp-arc/frontend/vite.config.ts b/applications/idp-arc/frontend/vite.config.ts index b16faaa..e276c1a 100644 --- a/applications/idp-arc/frontend/vite.config.ts +++ b/applications/idp-arc/frontend/vite.config.ts @@ -1,13 +1,23 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' import type { IncomingMessage } from 'node:http' +// Must use loadEnv(), not process.env — Vite doesn't load .env into process.env for this +// file, so process.env here silently falls back to the production domain. +const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), '') +const BASE_DOMAIN = env.VITE_OSB_BASE_DOMAIN || 'v2dev.opensourcebrain.org' +const PROTOCOL = env.VITE_OSB_PROTOCOL || 'https' + // The real JupyterHub host — matches production nginx proxy_pass target. -// www.v2dev.opensourcebrain.org blocks PUT on /jupyter-proxy/; lab. does not. -const LAB_ORIGIN = 'https://lab.v2dev.opensourcebrain.org' +// www. blocks PUT on /jupyter-proxy/; the lab subdomain does not. +const LAB_ORIGIN = `${PROTOCOL}://lab.${BASE_DOMAIN}` +const WWW_ORIGIN = `${PROTOCOL}://www.${BASE_DOMAIN}` const DEV_ORIGIN = 'http://localhost:5173' +// eslint-disable-next-line no-console +console.log(`[vite] proxying OSB -> ${WWW_ORIGIN} (jupyter: ${LAB_ORIGIN})`) + // Mirrors the production nginx proxy_redirect rules (default.conf lines 71-72): // proxy_redirect https://lab.v2dev.opensourcebrain.org/ https://$host/jupyter-proxy/; // proxy_redirect / https://$host/jupyter-proxy/; @@ -81,7 +91,7 @@ export default defineConfig({ }, proxy: { '/api-proxy': { - target: 'https://www.v2dev.opensourcebrain.org', + target: WWW_ORIGIN, changeOrigin: true, rewrite: (path) => path.replace(/^\/api-proxy/, ''), }, @@ -131,7 +141,7 @@ export default defineConfig({ // and /oauth/callback — without proxying these the redirects hit Vite's SPA // fallback and the per-server cookie (needed for write access) is never set. '/oauth': { - target: 'https://www.v2dev.opensourcebrain.org', + target: WWW_ORIGIN, changeOrigin: true, cookieDomainRewrite: '', configure: (proxy) => { diff --git a/applications/idp-arc/frontend/yarn.lock b/applications/idp-arc/frontend/yarn.lock index 6da3586..ecd6b8e 100644 --- a/applications/idp-arc/frontend/yarn.lock +++ b/applications/idp-arc/frontend/yarn.lock @@ -830,6 +830,11 @@ dependencies: csstype "^3.2.2" +"@types/spark-md5@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/spark-md5/-/spark-md5-3.0.5.tgz#eddec8639217e518c26e9e221ff56bf5f5f5c900" + integrity sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg== + "@typescript-eslint/eslint-plugin@8.56.0": version "8.56.0" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz" @@ -1872,6 +1877,11 @@ source-map@^0.5.7: resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== +spark-md5@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" + integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== + strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"