From 88efdf424988c08921c9ee5cc6a74f53bd73c42c Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 16 Sep 2026 13:14:46 +0530 Subject: [PATCH 1/5] IDP-43: ignore local Claude config and frontend env files --- .gitignore | 4 +++- applications/idp-arc/frontend/.gitignore | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 applications/idp-arc/frontend/.gitignore 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.* + From 9828c95d728d77df347a66a8e2c7891b19bd57a0 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 16 Sep 2026 13:14:53 +0530 Subject: [PATCH 2/5] IDP-43: add DANDI multipart upload client, hashing and use-case --- applications/idp-arc/frontend/package.json | 6 +- .../idp-arc/frontend/src/core/dandiEtag.ts | 62 ++++++++++ .../frontend/src/core/ports/IDandiApi.ts | 63 ++++++++++ .../idp-arc/frontend/src/core/ports/index.ts | 2 +- .../core/use-cases/createAndUploadToDandi.ts | 109 ++++++++++++++++++ .../idp-arc/frontend/src/data/protocols.json | 12 ++ .../frontend/src/infra/dandiApiClient.ts | 81 +++++++++++++ .../src/infra/mocks/mockDandiApiClient.ts | 44 +++++++ applications/idp-arc/frontend/yarn.lock | 10 ++ 9 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 applications/idp-arc/frontend/src/core/dandiEtag.ts create mode 100644 applications/idp-arc/frontend/src/core/ports/IDandiApi.ts create mode 100644 applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts create mode 100644 applications/idp-arc/frontend/src/infra/dandiApiClient.ts create mode 100644 applications/idp-arc/frontend/src/infra/mocks/mockDandiApiClient.ts 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/core/dandiEtag.ts b/applications/idp-arc/frontend/src/core/dandiEtag.ts new file mode 100644 index 0000000..bbab07e --- /dev/null +++ b/applications/idp-arc/frontend/src/core/dandiEtag.ts @@ -0,0 +1,62 @@ +import SparkMD5 from 'spark-md5' + +/** + * S3/DANDI multipart-style content digest, computed entirely client-side since bytes never + * reach our backend (see IDP-43 architecture notes — Route A). AssetBlob.etag on the real + * EMBER-DANDI API is validated against `^[0-9a-f]{32}-\d{1,5}$`, so this format is required + * even for a single-part file — it is not an optimisation that can be skipped at small sizes. + * + * Confirmed live against EMBER-DANDI on 2026-09-11: declaring a 150 MiB upload at + * /uploads/initialize/ returned parts of exactly 64 MiB, 64 MiB, 22 MiB — this constant is + * correct, not just recalled. The single-part algorithm itself is also confirmed: a real + * 4-byte upload's S3 CompleteMultipartUpload ETag matched this exact computation independently + * done in Python. + */ +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..4d55e35 --- /dev/null +++ b/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts @@ -0,0 +1,63 @@ +/** + * IDandiApi — abstraction over idp-arc's own backend endpoints that broker the DANDI upload + * (the admin key never reaches the browser — see IDP-43 architecture notes). + */ + +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 an explanation of why it didn't run. + * The backend runs it synchronously as part of finalize now — see FinalizeUploadInput's + * scriptUrl — so by the time this promise resolves the script has already finished. */ + 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 + /** Publicly-reachable URL of the selected protocol's analysis script, from protocols.json. + * The backend fetches and runs this itself as part of finalize (see jupyter_kernel_client.py) + * — no separate run step from the browser any more — so it must be reachable from OSB's + * cluster, not a idp-arc-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's bytes straight to S3 via its presigned URL. Returns the ETag S3 assigns + * this 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/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/use-cases/createAndUploadToDandi.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts new file mode 100644 index 0000000..2570f86 --- /dev/null +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts @@ -0,0 +1,109 @@ +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 +} + +/** + * createAndUploadToDandi use-case (Route A — see IDP-43 architecture notes) + * + * 1. Compute the dandi-etag client-side (bytes never reach idp-arc's backend) + * 2. POST /dandi/upload/init — backend derives the path from the caller's token, + * brokers DANDI's `initialize` call with the admin key, returns presigned S3 part URLs + * 3. PUT each part straight to S3 from the browser + * 4. POST /dandi/upload/finalize — backend completes/validates/registers with DANDI, + * then creates/attaches the OSB workspace + */ +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: compute the etag ────────────────────────────────────────── + 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 straight to S3 ────────────────────────────── + // Skipped entirely when DANDI already has this exact content (deduplicated): there are + // no parts and no upload_id, just a blob_id to attach a new asset to. + 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 + + // ── Step 4: finalize — DANDI completion/validation, OSB attach, AND run the script ── + // No Argo yet: the backend now blocks inside this one call through spawning the + // workspace's JupyterLab server and executing the script in it — several minutes in the + // worst case, not the few seconds finalize used to take. The token has to outlive the + // WHOLE call (the backend uses it at the very end too, for the JupyterHub/kernel calls), + // so a 30s validity floor is not enough. Asking for 600 forces the freshest possible + // token right before the call — the best the frontend can do — but if Keycloak's realm + // issues access tokens with a shorter total lifetime than the run takes, the token can + // still expire mid-request; that residual risk needs a realm setting or backend-side + // token refresh to close fully, not something fixable from here. + 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/data/protocols.json b/applications/idp-arc/frontend/src/data/protocols.json index a5b72bb..0b10ea7 100644 --- a/applications/idp-arc/frontend/src/data/protocols.json +++ b/applications/idp-arc/frontend/src/data/protocols.json @@ -2,31 +2,43 @@ { "name": "Two arm bandit task", "desc": "Test the flexibility using MED", + "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." }, { "name": "ASST digging task", "desc": "Attentional set-shifting task", + "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." }, { "name": "Four-choice reversal digging task", "desc": "Reversal learning assessment", + "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." }, { "name": "Open field task", "desc": "Locomotion and anxiety assessment", + "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." }, { "name": "Elevated plus maze", "desc": "Anxiety and risk-taking behavior", + "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." }, { "name": "Foraging task", "desc": "Patch-leaving and optimal foraging", + "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–benefit decision-making circuits." } ] 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..0c476af --- /dev/null +++ b/applications/idp-arc/frontend/src/infra/dandiApiClient.ts @@ -0,0 +1,81 @@ +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 likely ' + + 'needs Access-Control-Expose-Headers: ETag in its CORS config (confirmed present on ' + + 'the public DANDI archive; not yet verified on EMBER-DANDI\'s own bucket).', + ) + } + 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/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/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" From cf73fc36089448afa8365669369606ce8a494fd0 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 16 Sep 2026 13:14:58 +0530 Subject: [PATCH 3/5] IDP-43: switch uploads to DANDI, drop the JupyterLab Contents API path --- .../idp-arc/frontend/src/app/container.ts | 34 ++- .../idp-arc/frontend/src/app/mockContainer.ts | 8 +- .../src/components/DataUploadDialog.tsx | 89 +++++-- .../frontend/src/core/ports/IJupyterApi.ts | 38 --- .../idp-arc/frontend/src/core/types.ts | 19 +- .../use-cases/createAndUploadWorkspace.ts | 115 -------- .../frontend/src/core/use-cases/index.ts | 2 - .../idp-arc/frontend/src/infra/index.ts | 1 - .../frontend/src/infra/jupyterApiClient.ts | 252 ------------------ .../idp-arc/frontend/src/infra/mocks/index.ts | 1 - .../src/infra/mocks/mockJupyterApiClient.ts | 52 ---- .../frontend/src/locales/en/common.json | 19 -- .../idp-arc/frontend/src/pages/Workspaces.tsx | 170 +----------- applications/idp-arc/frontend/vite.config.ts | 20 +- 14 files changed, 114 insertions(+), 706 deletions(-) delete mode 100644 applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts delete mode 100644 applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts delete mode 100644 applications/idp-arc/frontend/src/infra/jupyterApiClient.ts delete mode 100644 applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index eba1946..f71e71c 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -18,42 +18,48 @@ 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' // ─── 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}` +// Target environment. Defaults to the shared dev deployment; override in .env.local to point +// at a local minikube OSB (see .vscode/plans/osb-local-deployment.md): +// VITE_OSB_BASE_DOMAIN=osb.local +// VITE_OSB_PROTOCOL=http # local is deployed with -dtls, so no TLS +// VITE_KEYCLOAK_URL=http://accounts.osb.local +// VITE_KEYCLOAK_REALM=ch # local realm is `ch`, the dev one is `osb2dev` +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) +/** 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..885e478 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' @@ -42,6 +42,8 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data uploadMessage: string /** ID of the workspace spawned in the current dialog session; drives retry behaviour. */ spawnedWorkspaceId: number | undefined + /** Live stdout from the protocol script, streamed as the workspace kernel produces it. */ + scriptOutput: string } const INITIAL_FORM: FormState = { @@ -53,10 +55,11 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data isDragging: false, uploadMessage: '', spawnedWorkspaceId: undefined, + scriptOutput: '', } const [form, setForm] = useState(INITIAL_FORM) - const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage, spawnedWorkspaceId } = form + const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage, spawnedWorkspaceId, scriptOutput } = form const [workspaces, setWorkspaces] = useState([]) const [loadingWorkspaces, setLoadingWorkspaces] = useState(false) const fileInputRef = useRef(null) @@ -92,57 +95,60 @@ 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( + // The backend now runs the protocol script itself, synchronously, as the last thing + // `finalize` does (see OSBv2 applications/workspaces/server/workspaces/service/jupyter_kernel_client.py) — no Argo yet, so this one + // call blocks through spawning the workspace's JupyterLab server and executing the script + // in it. There is no separate browser-driven run step any more; `scriptOutput` arrives with + // the same `done` state as the workspace id. + 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, + ...(state.scriptOutput !== undefined ? { scriptOutput: state.scriptOutput } : {}), ...(state.phase === 'done' ? { step: 'success' } : {}), ...(state.phase === 'error' ? { step: 'upload' } : {}), })) @@ -404,8 +410,33 @@ 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.'} + + {/* Live output from the protocol script running in the workspace kernel. */} + {scriptOutput && ( + + {scriptOutput} + + )} )} 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/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/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/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/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/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/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..4712590 100644 --- a/applications/idp-arc/frontend/src/locales/en/common.json +++ b/applications/idp-arc/frontend/src/locales/en/common.json @@ -17,7 +17,6 @@ "title": "OSB Workspaces", "loading": "Loading workspaces…", "empty": "No workspaces found.", - "newButton": "+ New workspace & upload file", "table": { "id": "ID", "name": "Name", @@ -26,24 +25,6 @@ "noDescription": "—", "noDate": "—" } - }, - "modal": { - "title": "New workspace & upload file", - "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." }, "collaborators": { "sectionTitle": "Collaborators", diff --git a/applications/idp-arc/frontend/src/pages/Workspaces.tsx b/applications/idp-arc/frontend/src/pages/Workspaces.tsx index 1a6a879..023a724 100644 --- a/applications/idp-arc/frontend/src/pages/Workspaces.tsx +++ b/applications/idp-arc/frontend/src/pages/Workspaces.tsx @@ -1,23 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { authClient, loadWorkspaces, createAndUpload, getWorkspaceUrl } from '../app/container' -import type { Workspace, UploadState } from '../core/types' +import { authClient, loadWorkspaces } from '../app/container' +import type { Workspace } from '../core/types' import { useAppContext } from '../AppContext' export default function Workspaces() { const { t } = useTranslation() - const { authState, tokenParsed, authError, username } = useAppContext() + const { authState, authError, username } = useAppContext() const [workspaces, setWorkspaces] = useState(null) const [workspacesError, setWorkspacesError] = useState(null) const [workspacesLoading, setWorkspacesLoading] = useState(false) - // 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 loadWorkspaceList = useCallback(() => { setWorkspacesLoading(true) setWorkspacesError(null) @@ -38,39 +31,6 @@ export default function Workspaces() { void (async () => { loadWorkspaceList() })() }, [authState, loadWorkspaceList]) - function openModal() { - setWorkspaceName('') - setSelectedFile(null) - setUploadState({ phase: 'idle', message: '' }) - abortRef.current = false - 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() - } - if (authState === 'loading') { return

{t('auth.initialising')}

} @@ -89,8 +49,6 @@ export default function Workspaces() { ) } - const isRunning = ['creating', 'spawning', 'waiting', 'uploading'].includes(uploadState.phase) - return (
@@ -102,7 +60,6 @@ export default function Workspaces() {

{t('workspaces.title')}

-
{workspacesLoading &&

{t('workspaces.loading')}

} {workspacesError && ( @@ -138,125 +95,6 @@ export default function Workspaces() { )}
- - {/* ── Upload modal ──────────────────────────────────────────────────── */} - {modalOpen && ( -
{ if (e.target === e.currentTarget) closeModal() }} - > -
-

{t('modal.title')}

- - {uploadState.phase === 'idle' && ( - <> - - - - -
- - -
- - )} - - {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) => { From c0dff77fb7b51559bf2dca26ceb4ef814abd3630 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 16 Sep 2026 13:24:09 +0530 Subject: [PATCH 4/5] IDP-43: restore named workspace creation, trim comments to the non-obvious --- .../idp-arc/frontend/src/app/container.ts | 10 +-- .../src/components/DataUploadDialog.tsx | 38 +-------- .../idp-arc/frontend/src/core/dandiEtag.ts | 12 +-- .../frontend/src/core/ports/IDandiApi.ts | 19 ++--- .../core/use-cases/createAndUploadToDandi.ts | 38 ++++----- .../src/core/use-cases/createWorkspace.ts | 13 ++++ .../frontend/src/infra/dandiApiClient.ts | 5 +- .../frontend/src/locales/en/common.json | 9 +++ .../idp-arc/frontend/src/pages/Workspaces.tsx | 77 ++++++++++++++++++- 9 files changed, 133 insertions(+), 88 deletions(-) create mode 100644 applications/idp-arc/frontend/src/core/use-cases/createWorkspace.ts diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index f71e71c..334f684 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -21,16 +21,11 @@ import { WorkspaceApiClient } from '../infra/workspaceApiClient' import { DandiApiClient } from '../infra/dandiApiClient' import { createLoadWorkspacesUseCase } from '../core/use-cases/loadWorkspaces' 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). -// Target environment. Defaults to the shared dev deployment; override in .env.local to point -// at a local minikube OSB (see .vscode/plans/osb-local-deployment.md): -// VITE_OSB_BASE_DOMAIN=osb.local -// VITE_OSB_PROTOCOL=http # local is deployed with -dtls, so no TLS -// VITE_KEYCLOAK_URL=http://accounts.osb.local -// VITE_KEYCLOAK_REALM=ch # local realm is `ch`, the dev one is `osb2dev` 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}` @@ -57,6 +52,9 @@ const dandiApi = new DandiApiClient(WORKSPACES_API) /** Refreshes the token then returns the workspace list. */ export const loadWorkspaces = createLoadWorkspacesUseCase(authClient, workspaceApi) +/** 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) diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx index 885e478..6be1e92 100644 --- a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -42,8 +42,6 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data uploadMessage: string /** ID of the workspace spawned in the current dialog session; drives retry behaviour. */ spawnedWorkspaceId: number | undefined - /** Live stdout from the protocol script, streamed as the workspace kernel produces it. */ - scriptOutput: string } const INITIAL_FORM: FormState = { @@ -55,11 +53,10 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data isDragging: false, uploadMessage: '', spawnedWorkspaceId: undefined, - scriptOutput: '', } const [form, setForm] = useState(INITIAL_FORM) - const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage, spawnedWorkspaceId, scriptOutput } = form + const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage, spawnedWorkspaceId } = form const [workspaces, setWorkspaces] = useState([]) const [loadingWorkspaces, setLoadingWorkspaces] = useState(false) const fileInputRef = useRef(null) @@ -120,11 +117,8 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data : undefined const uploadWorkspaceId = isRetry ? spawnedWorkspaceId : resolvedId - // The backend now runs the protocol script itself, synchronously, as the last thing - // `finalize` does (see OSBv2 applications/workspaces/server/workspaces/service/jupyter_kernel_client.py) — no Argo yet, so this one - // call blocks through spawning the workspace's JupyterLab server and executing the script - // in it. There is no separate browser-driven run step any more; `scriptOutput` arrives with - // the same `done` state as the workspace id. + // finalize is synchronous: it also spawns the workspace and runs the script, so this + // one call can block for minutes. await createAndUploadToDandi( { taskId: slugify(protocol || behavioralTask), @@ -148,7 +142,6 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data setForm((prev) => ({ ...prev, uploadMessage: state.phase === 'error' ? (state.error ?? state.message) : state.message, - ...(state.scriptOutput !== undefined ? { scriptOutput: state.scriptOutput } : {}), ...(state.phase === 'done' ? { step: 'success' } : {}), ...(state.phase === 'error' ? { step: 'upload' } : {}), })) @@ -412,31 +405,6 @@ export default function DataUploadDialog({ open, onClose, onAuthRequired }: Data {uploadMessage || 'You can close this dialog.'} - - {/* Live output from the protocol script running in the workspace kernel. */} - {scriptOutput && ( - - {scriptOutput} - - )} )} diff --git a/applications/idp-arc/frontend/src/core/dandiEtag.ts b/applications/idp-arc/frontend/src/core/dandiEtag.ts index bbab07e..a081825 100644 --- a/applications/idp-arc/frontend/src/core/dandiEtag.ts +++ b/applications/idp-arc/frontend/src/core/dandiEtag.ts @@ -1,16 +1,10 @@ import SparkMD5 from 'spark-md5' /** - * S3/DANDI multipart-style content digest, computed entirely client-side since bytes never - * reach our backend (see IDP-43 architecture notes — Route A). AssetBlob.etag on the real - * EMBER-DANDI API is validated against `^[0-9a-f]{32}-\d{1,5}$`, so this format is required - * even for a single-part file — it is not an optimisation that can be skipped at small sizes. + * Part size DANDI splits uploads into. Must match theirs exactly or the ETag won't agree. * - * Confirmed live against EMBER-DANDI on 2026-09-11: declaring a 150 MiB upload at - * /uploads/initialize/ returned parts of exactly 64 MiB, 64 MiB, 22 MiB — this constant is - * correct, not just recalled. The single-part algorithm itself is also confirmed: a real - * 4-byte upload's S3 CompleteMultipartUpload ETag matched this exact computation independently - * done in Python. + * 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 diff --git a/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts b/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts index 4d55e35..77ab635 100644 --- a/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts +++ b/applications/idp-arc/frontend/src/core/ports/IDandiApi.ts @@ -1,7 +1,4 @@ -/** - * IDandiApi — abstraction over idp-arc's own backend endpoints that broker the DANDI upload - * (the admin key never reaches the browser — see IDP-43 architecture notes). - */ +/** Abstraction over the OSB endpoints that broker the DANDI upload. */ export interface UploadPart { partNumber: number @@ -28,9 +25,7 @@ export interface UploadFinalizeResult { assetPath: string dandisetUrl: string workspaceId: number - /** Everything the protocol script printed, or an explanation of why it didn't run. - * The backend runs it synchronously as part of finalize now — see FinalizeUploadInput's - * scriptUrl — so by the time this promise resolves the script has already finished. */ + /** Everything the protocol script printed, or why it didn't run. */ scriptOutput?: string } @@ -43,10 +38,8 @@ export interface FinalizeUploadInput { workspaceId?: number workspaceName?: string blobId?: string - /** Publicly-reachable URL of the selected protocol's analysis script, from protocols.json. - * The backend fetches and runs this itself as part of finalize (see jupyter_kernel_client.py) - * — no separate run step from the browser any more — so it must be reachable from OSB's - * cluster, not a idp-arc-local address. */ + /** 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 @@ -55,8 +48,8 @@ export interface FinalizeUploadInput { export interface IDandiApi { initUpload(token: string, taskId: string, filename: string, size: number, dandiEtag: string): Promise - /** PUTs one part's bytes straight to S3 via its presigned URL. Returns the ETag S3 assigns - * this part (read from the response header — requires the bucket to expose it via CORS). */ + /** 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/use-cases/createAndUploadToDandi.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts index 2570f86..a4c7afb 100644 --- a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadToDandi.ts @@ -19,14 +19,17 @@ export interface CreateAndUploadToDandiInput { } /** - * createAndUploadToDandi use-case (Route A — see IDP-43 architecture notes) + * Uploads a file to DANDI and attaches it to an OSB workspace. * - * 1. Compute the dandi-etag client-side (bytes never reach idp-arc's backend) - * 2. POST /dandi/upload/init — backend derives the path from the caller's token, - * brokers DANDI's `initialize` call with the admin key, returns presigned S3 part URLs - * 3. PUT each part straight to S3 from the browser - * 4. POST /dandi/upload/finalize — backend completes/validates/registers with DANDI, - * then creates/attaches the 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, @@ -40,7 +43,7 @@ export function createCreateAndUploadToDandiUseCase( const { taskId, file, workspaceId, workspaceName, scriptUrl, scriptName } = input try { - // ── Step 1: compute the etag ────────────────────────────────────────── + // ── 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 @@ -51,9 +54,9 @@ export function createCreateAndUploadToDandiUseCase( const init = await dandiApi.initUpload(initToken, taskId, file.name, file.size, etag) if (abortRef.current) return null - // ── Step 3: PUT each part straight to S3 ────────────────────────────── - // Skipped entirely when DANDI already has this exact content (deduplicated): there are - // no parts and no upload_id, just a blob_id to attach a new asset to. + // ── 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 }) @@ -70,16 +73,9 @@ export function createCreateAndUploadToDandiUseCase( } if (abortRef.current) return null - // ── Step 4: finalize — DANDI completion/validation, OSB attach, AND run the script ── - // No Argo yet: the backend now blocks inside this one call through spawning the - // workspace's JupyterLab server and executing the script in it — several minutes in the - // worst case, not the few seconds finalize used to take. The token has to outlive the - // WHOLE call (the backend uses it at the very end too, for the JupyterHub/kernel calls), - // so a 30s validity floor is not enough. Asking for 600 forces the freshest possible - // token right before the call — the best the frontend can do — but if Keycloak's realm - // issues access tokens with a shorter total lifetime than the run takes, the token can - // still expire mid-request; that residual risk needs a realm setting or backend-side - // token refresh to close fully, not something fixable from here. + // 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, { 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/infra/dandiApiClient.ts b/applications/idp-arc/frontend/src/infra/dandiApiClient.ts index 0c476af..ef0e4f8 100644 --- a/applications/idp-arc/frontend/src/infra/dandiApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/dandiApiClient.ts @@ -38,9 +38,8 @@ export class DandiApiClient implements IDandiApi { 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 likely ' + - 'needs Access-Control-Expose-Headers: ETag in its CORS config (confirmed present on ' + - 'the public DANDI archive; not yet verified on EMBER-DANDI\'s own bucket).', + '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('"', '') diff --git a/applications/idp-arc/frontend/src/locales/en/common.json b/applications/idp-arc/frontend/src/locales/en/common.json index 4712590..a9d528a 100644 --- a/applications/idp-arc/frontend/src/locales/en/common.json +++ b/applications/idp-arc/frontend/src/locales/en/common.json @@ -17,6 +17,7 @@ "title": "OSB Workspaces", "loading": "Loading workspaces…", "empty": "No workspaces found.", + "newButton": "+ New workspace", "table": { "id": "ID", "name": "Name", @@ -25,6 +26,14 @@ "noDescription": "—", "noDate": "—" } + }, + "modal": { + "title": "New workspace", + "workspaceNameLabel": "Workspace name", + "workspaceNamePlaceholder": "My new workspace", + "cancel": "Cancel", + "create": "Create", + "creationFailed": "Failed to create workspace: {{message}}" }, "collaborators": { "sectionTitle": "Collaborators", diff --git a/applications/idp-arc/frontend/src/pages/Workspaces.tsx b/applications/idp-arc/frontend/src/pages/Workspaces.tsx index 023a724..2db413d 100644 --- a/applications/idp-arc/frontend/src/pages/Workspaces.tsx +++ b/applications/idp-arc/frontend/src/pages/Workspaces.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { authClient, loadWorkspaces } from '../app/container' +import { authClient, loadWorkspaces, createWorkspace } from '../app/container' import type { Workspace } from '../core/types' import { useAppContext } from '../AppContext' @@ -11,6 +11,12 @@ export default function Workspaces() { const [workspacesError, setWorkspacesError] = useState(null) const [workspacesLoading, setWorkspacesLoading] = useState(false) + // Modal state + const [modalOpen, setModalOpen] = useState(false) + const [workspaceName, setWorkspaceName] = useState('') + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const loadWorkspaceList = useCallback(() => { setWorkspacesLoading(true) setWorkspacesError(null) @@ -31,6 +37,31 @@ export default function Workspaces() { void (async () => { loadWorkspaceList() })() }, [authState, loadWorkspaceList]) + function openModal() { + setWorkspaceName('') + setCreateError(null) + setModalOpen(true) + } + + function closeModal() { + setModalOpen(false) + } + + 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') { return

{t('auth.initialising')}

} @@ -60,6 +91,7 @@ export default function Workspaces() {

{t('workspaces.title')}

+
{workspacesLoading &&

{t('workspaces.loading')}

} {workspacesError && ( @@ -95,6 +127,49 @@ export default function Workspaces() { )}
+ + {/* ── New workspace modal ───────────────────────────────────────────── */} + {modalOpen && ( +
{ if (e.target === e.currentTarget) closeModal() }} + > +
+

{t('modal.title')}

+ + + + {createError && ( +

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

+ )} + +
+ + +
+
+
+ )}
) } From 1e34c5946663c2c080392b6be1996066dc975849 Mon Sep 17 00:00:00 2001 From: Dario Del Piano Date: Wed, 16 Sep 2026 15:57:38 +0200 Subject: [PATCH 5/5] fixing silent sso and protocols json --- .../idp-arc/frontend/src/data/protocols.json | 24 ++++++++++++++----- .../frontend/src/infra/keycloakAuthClient.ts | 7 ++++++ .../frontend/src/pages/ProtocolsPage.tsx | 4 ++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/applications/idp-arc/frontend/src/data/protocols.json b/applications/idp-arc/frontend/src/data/protocols.json index 0b10ea7..c9262d0 100644 --- a/applications/idp-arc/frontend/src/data/protocols.json +++ b/applications/idp-arc/frontend/src/data/protocols.json @@ -4,41 +4,53 @@ "desc": "Test the flexibility using MED", "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." + "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", "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." + "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", "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." + "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", "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." + "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", "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." + "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", "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–benefit decision-making circuits." + "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/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/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() {