feat(api): async job queue with comprehensive test coverage (#36)
Browse files* docs(bugs): add gateway timeout audit and deployment checklist
- Document Bug 003: HF Spaces ~60s proxy timeout risk for ML inference
- Add comprehensive deployment checklist with verification status
- Include E2E flow audit diagram for debugging reference
- Verify existing bug fixes (001, 002) are correct and complete
* feat(api): async job queue to eliminate gateway timeout
Implement async job queue pattern to handle HuggingFace Spaces' ~60s
gateway timeout for long-running ML inference (30-60s typical).
## Problem
- HF Spaces proxy has hard ~60s timeout
- DeepISLES inference takes 30-60s
- Intermittent 504 Gateway Timeout errors
## Solution
POST /api/segment now returns 202 Accepted immediately with job ID.
Frontend polls GET /api/jobs/{id} every 2s for status/progress/results.
No single request exceeds the timeout - completely eliminates the issue.
### Backend Changes
- Add job_store.py: Thread-safe in-memory job storage with TTL cleanup
- Update routes.py: Async job creation + background task execution
- Update schemas.py: CreateJobResponse, JobStatusResponse types
- Update main.py: Lifespan handler for job store initialization
### Frontend Changes
- Add ProgressIndicator component with animated progress bar
- Update useSegmentation hook with polling logic
- Update api/client.ts with createSegmentJob/getJobStatus methods
- Update App.tsx to show progress and cancel button
- Update types/index.ts with JobStatus types
- Update mock handlers for testing
### Documentation
- Add docs/specs/async-job-queue.md (full spec)
- Update docs/bugs/003-gateway-timeout-long-inference.md (FIXED)
- Update docs/bugs/README.md (checklist, E2E flow v2.0)
Performance: Initial response <1s (was 30-60s), zero timeout risk
* fix(test): comprehensive test fixes for async job queue
Frontend Test Fixes:
- Remove fake timers from App.test.tsx (incompatible with MSW polling)
- Use real timers with configurable mock job duration (500ms)
- Add setMockJobDuration() to handlers.ts for test configuration
- Fix ambiguous element queries (multiple elements with same text)
- Add proper timeout for multi-run test (15s for two job cycles)
- Use behavior-based assertions instead of implementation details
Backend Tests:
- Add comprehensive unit tests for job_store.py (20 tests)
- Job dataclass tests: elapsed time, to_dict(), status handling
- JobStore tests: create, get, start, update, complete, fail
- Cleanup tests: TTL expiration, file removal
- Global store tests: init/get patterns
- Update test_endpoints.py for async API (11 tests)
- POST /api/segment returns 202 with job ID
- GET /api/jobs/{id} returns status/progress/result
- Error handling tests
All tests now pass:
- Frontend: 63 tests
- Backend: 31 tests (API only)
* fix(lint): remove unused imports and fix type annotation
- Remove unused `time` and `field` imports from job_store.py
- Import AsyncIterator from collections.abc instead of typing
- Prefix unused `app` parameter with underscore in lifespan function
* style: format schemas.py to pass CI format check
* fix(e2e): update fixtures for async job queue API pattern
The e2e fixtures were mocking the old sync API (POST /api/segment returns
result directly). Updated to match the new async job queue pattern:
- POST /api/segment returns 202 with jobId
- GET /api/jobs/:jobId returns job status with progress
- Jobs progress over ~1 second from pending → running → completed
- Fixed processingText locator to use button role (avoid strict mode violation)
* fix(security): apply CodeRabbit security and quality fixes
Security fixes (CRITICAL):
- Add path traversal protection in cleanup_old_jobs()
- Validate job_id format to allow only alphanumeric, hyphens, underscores
- Use full UUID hex instead of truncated 8-char (prevents collisions)
- Sanitize error messages - don't expose raw exceptions to clients
Code quality improvements:
- Use 'is not None' checks in Job.to_dict() instead of truthiness
- Ensure started_at is set before computing elapsed time
- Prevent job_id overwrites with KeyError on duplicate
- Don't log case_id (potentially sensitive medical data)
- Fix misleading comment about lifespan vs import order
Documentation:
- Add 'text' language specifier to code fence blocks
- Note multi-worker would require shared store (Redis/DB)
---------
Co-authored-by: Claude <noreply@anthropic.com>
- e2e/fixtures.ts +126 -27
- e2e/pages/HomePage.ts +1 -1
- src/App.test.tsx +102 -32
- src/App.tsx +48 -5
- src/api/__tests__/client.test.ts +35 -20
- src/api/client.ts +52 -4
- src/components/ProgressIndicator.tsx +84 -0
- src/components/index.ts +1 -0
- src/hooks/__tests__/useSegmentation.test.tsx +95 -29
- src/hooks/useSegmentation.ts +191 -49
- src/mocks/handlers.ts +169 -15
- src/types/index.ts +25 -0
|
@@ -1,48 +1,147 @@
|
|
| 1 |
-
import { test as base, expect } from '@playwright/test'
|
| 2 |
|
| 3 |
-
// API response mocks matching
|
| 4 |
const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
diceScore: 0.847,
|
| 8 |
volumeMl: 15.32,
|
| 9 |
elapsedSeconds: 12.5,
|
| 10 |
// Use real public NIfTI for visual testing (NiiVue demo image)
|
| 11 |
dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
|
| 12 |
predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
|
| 13 |
-
}
|
| 14 |
|
| 15 |
-
//
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
})
|
| 26 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
//
|
| 29 |
-
|
| 30 |
-
const request = route.request()
|
| 31 |
-
const body = JSON.parse(request.postData() || '{}') as { case_id?: string }
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
route.fulfill({
|
| 37 |
-
status:
|
| 38 |
contentType: 'application/json',
|
| 39 |
-
body: JSON.stringify({
|
| 40 |
-
...MOCK_SEGMENT_RESPONSE,
|
| 41 |
-
caseId: body.case_id || 'sub-stroke0001',
|
| 42 |
-
}),
|
| 43 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
})
|
|
|
|
|
|
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
await use(page)
|
| 47 |
},
|
| 48 |
})
|
|
|
|
| 1 |
+
import { test as base, expect, Page } from '@playwright/test'
|
| 2 |
|
| 3 |
+
// API response mocks matching the async job queue pattern
|
| 4 |
const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
|
| 5 |
+
|
| 6 |
+
// Track jobs for the async pattern
|
| 7 |
+
interface MockJob {
|
| 8 |
+
id: string
|
| 9 |
+
caseId: string
|
| 10 |
+
status: 'pending' | 'running' | 'completed' | 'failed'
|
| 11 |
+
progress: number
|
| 12 |
+
progressMessage: string
|
| 13 |
+
createdAt: number
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Job store per test (reset for each test)
|
| 17 |
+
const createJobStore = () => {
|
| 18 |
+
const jobs = new Map<string, MockJob>()
|
| 19 |
+
let jobCounter = 0
|
| 20 |
+
|
| 21 |
+
return {
|
| 22 |
+
createJob(caseId: string): MockJob {
|
| 23 |
+
const jobId = `e2e-job-${++jobCounter}`
|
| 24 |
+
const job: MockJob = {
|
| 25 |
+
id: jobId,
|
| 26 |
+
caseId,
|
| 27 |
+
status: 'pending',
|
| 28 |
+
progress: 0,
|
| 29 |
+
progressMessage: 'Job queued',
|
| 30 |
+
createdAt: Date.now(),
|
| 31 |
+
}
|
| 32 |
+
jobs.set(jobId, job)
|
| 33 |
+
return job
|
| 34 |
+
},
|
| 35 |
+
getJob(jobId: string): MockJob | undefined {
|
| 36 |
+
return jobs.get(jobId)
|
| 37 |
+
},
|
| 38 |
+
updateJobProgress(job: MockJob): MockJob {
|
| 39 |
+
// Simulate job progression over 1 second
|
| 40 |
+
const elapsed = Date.now() - job.createdAt
|
| 41 |
+
if (elapsed < 200) {
|
| 42 |
+
return { ...job, status: 'running', progress: 25, progressMessage: 'Loading case data...' }
|
| 43 |
+
} else if (elapsed < 500) {
|
| 44 |
+
return { ...job, status: 'running', progress: 50, progressMessage: 'Running inference...' }
|
| 45 |
+
} else if (elapsed < 800) {
|
| 46 |
+
return { ...job, status: 'running', progress: 75, progressMessage: 'Processing results...' }
|
| 47 |
+
} else {
|
| 48 |
+
return { ...job, status: 'completed', progress: 100, progressMessage: 'Segmentation complete' }
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Mock completed job result
|
| 55 |
+
const createMockResult = (caseId: string) => ({
|
| 56 |
+
caseId,
|
| 57 |
diceScore: 0.847,
|
| 58 |
volumeMl: 15.32,
|
| 59 |
elapsedSeconds: 12.5,
|
| 60 |
// Use real public NIfTI for visual testing (NiiVue demo image)
|
| 61 |
dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
|
| 62 |
predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
|
| 63 |
+
})
|
| 64 |
|
| 65 |
+
// Setup API mocking for async job queue pattern
|
| 66 |
+
async function setupApiMocks(page: Page) {
|
| 67 |
+
const jobStore = createJobStore()
|
| 68 |
+
|
| 69 |
+
// Mock GET /api/cases
|
| 70 |
+
await page.route('**/api/cases', (route) => {
|
| 71 |
+
route.fulfill({
|
| 72 |
+
status: 200,
|
| 73 |
+
contentType: 'application/json',
|
| 74 |
+
body: JSON.stringify({ cases: MOCK_CASES }),
|
|
|
|
| 75 |
})
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
// Mock POST /api/segment - returns 202 with job ID (async pattern)
|
| 79 |
+
await page.route('**/api/segment', async (route) => {
|
| 80 |
+
const request = route.request()
|
| 81 |
+
const body = JSON.parse(request.postData() || '{}') as { case_id?: string }
|
| 82 |
+
const caseId = body.case_id || 'sub-stroke0001'
|
| 83 |
|
| 84 |
+
// Create a new job
|
| 85 |
+
const job = jobStore.createJob(caseId)
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
// Small delay to simulate network
|
| 88 |
+
await new Promise((r) => setTimeout(r, 50))
|
| 89 |
+
|
| 90 |
+
route.fulfill({
|
| 91 |
+
status: 202,
|
| 92 |
+
contentType: 'application/json',
|
| 93 |
+
body: JSON.stringify({
|
| 94 |
+
jobId: job.id,
|
| 95 |
+
status: 'pending',
|
| 96 |
+
message: `Segmentation job queued for ${caseId}`,
|
| 97 |
+
}),
|
| 98 |
+
})
|
| 99 |
+
})
|
| 100 |
|
| 101 |
+
// Mock GET /api/jobs/:jobId - returns job status (for polling)
|
| 102 |
+
await page.route('**/api/jobs/*', async (route) => {
|
| 103 |
+
const url = route.request().url()
|
| 104 |
+
const jobId = url.split('/api/jobs/')[1]
|
| 105 |
+
|
| 106 |
+
const job = jobStore.getJob(jobId)
|
| 107 |
+
if (!job) {
|
| 108 |
route.fulfill({
|
| 109 |
+
status: 404,
|
| 110 |
contentType: 'application/json',
|
| 111 |
+
body: JSON.stringify({ detail: `Job not found: ${jobId}` }),
|
|
|
|
|
|
|
|
|
|
| 112 |
})
|
| 113 |
+
return
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Update job progress based on elapsed time
|
| 117 |
+
const updatedJob = jobStore.updateJobProgress(job)
|
| 118 |
+
|
| 119 |
+
const response: Record<string, unknown> = {
|
| 120 |
+
jobId: updatedJob.id,
|
| 121 |
+
status: updatedJob.status,
|
| 122 |
+
progress: updatedJob.progress,
|
| 123 |
+
progressMessage: updatedJob.progressMessage,
|
| 124 |
+
elapsedSeconds: (Date.now() - updatedJob.createdAt) / 1000,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Include result when completed
|
| 128 |
+
if (updatedJob.status === 'completed') {
|
| 129 |
+
response.result = createMockResult(updatedJob.caseId)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
route.fulfill({
|
| 133 |
+
status: 200,
|
| 134 |
+
contentType: 'application/json',
|
| 135 |
+
body: JSON.stringify(response),
|
| 136 |
})
|
| 137 |
+
})
|
| 138 |
+
}
|
| 139 |
|
| 140 |
+
// Extend base test to include API mocking
|
| 141 |
+
export const test = base.extend({
|
| 142 |
+
// Auto-mock API routes for every test
|
| 143 |
+
page: async ({ page }, use) => {
|
| 144 |
+
await setupApiMocks(page)
|
| 145 |
await use(page)
|
| 146 |
},
|
| 147 |
})
|
|
@@ -19,7 +19,7 @@ export class HomePage {
|
|
| 19 |
})
|
| 20 |
this.caseSelector = page.getByRole('combobox')
|
| 21 |
this.runButton = page.getByRole('button', { name: /run segmentation/i })
|
| 22 |
-
this.processingText = page.
|
| 23 |
this.metricsPanel = page.getByRole('heading', { name: /results/i })
|
| 24 |
this.diceScore = page.getByText(/0\.\d{3}/)
|
| 25 |
this.viewer = page.locator('canvas')
|
|
|
|
| 19 |
})
|
| 20 |
this.caseSelector = page.getByRole('combobox')
|
| 21 |
this.runButton = page.getByRole('button', { name: /run segmentation/i })
|
| 22 |
+
this.processingText = page.getByRole('button', { name: /processing/i })
|
| 23 |
this.metricsPanel = page.getByRole('heading', { name: /results/i })
|
| 24 |
this.diceScore = page.getByText(/0\.\d{3}/)
|
| 25 |
this.viewer = page.locator('canvas')
|
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
import { render, screen, waitFor } from '@testing-library/react'
|
| 3 |
import userEvent from '@testing-library/user-event'
|
| 4 |
import { server } from './mocks/server'
|
| 5 |
-
import { errorHandlers } from './mocks/handlers'
|
| 6 |
import App from './App'
|
| 7 |
|
| 8 |
// Mock NiiVue to avoid WebGL in tests
|
|
@@ -19,6 +19,17 @@ vi.mock('@niivue/niivue', () => ({
|
|
| 19 |
}))
|
| 20 |
|
| 21 |
describe('App Integration', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
describe('Initial Render', () => {
|
| 23 |
it('renders main heading', () => {
|
| 24 |
render(<App />)
|
|
@@ -94,10 +105,11 @@ describe('App Integration', () => {
|
|
| 94 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 95 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 96 |
|
| 97 |
-
|
|
|
|
| 98 |
})
|
| 99 |
|
| 100 |
-
it('
|
| 101 |
const user = userEvent.setup()
|
| 102 |
render(<App />)
|
| 103 |
|
|
@@ -108,12 +120,33 @@ describe('App Integration', () => {
|
|
| 108 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 109 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
await waitFor(() => {
|
| 112 |
-
expect(screen.
|
| 113 |
})
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
expect(screen.getByText('15.32 mL')).toBeInTheDocument()
|
| 116 |
-
expect(screen.getByText(/12\.5s/)).toBeInTheDocument()
|
| 117 |
})
|
| 118 |
|
| 119 |
it('displays viewer after successful segmentation', async () => {
|
|
@@ -127,9 +160,13 @@ describe('App Integration', () => {
|
|
| 127 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 128 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
})
|
| 134 |
|
| 135 |
it('hides placeholder after successful segmentation', async () => {
|
|
@@ -143,19 +180,37 @@ describe('App Integration', () => {
|
|
| 143 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 144 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
expect(
|
| 151 |
screen.queryByText(/select a case and run segmentation/i)
|
| 152 |
).not.toBeInTheDocument()
|
| 153 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
})
|
| 155 |
|
| 156 |
describe('Error Handling', () => {
|
| 157 |
-
it('shows error when
|
| 158 |
-
server.use(errorHandlers.
|
| 159 |
const user = userEvent.setup()
|
| 160 |
|
| 161 |
render(<App />)
|
|
@@ -171,11 +226,11 @@ describe('App Integration', () => {
|
|
| 171 |
expect(screen.getByRole('alert')).toBeInTheDocument()
|
| 172 |
})
|
| 173 |
|
| 174 |
-
expect(screen.getByText(/
|
| 175 |
})
|
| 176 |
|
| 177 |
it('allows retry after error', async () => {
|
| 178 |
-
server.use(errorHandlers.
|
| 179 |
const user = userEvent.setup()
|
| 180 |
|
| 181 |
render(<App />)
|
|
@@ -197,16 +252,20 @@ describe('App Integration', () => {
|
|
| 197 |
// Retry
|
| 198 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
| 205 |
})
|
| 206 |
})
|
| 207 |
|
| 208 |
describe('Multiple Runs', () => {
|
| 209 |
-
it('allows running segmentation on different cases', async () => {
|
| 210 |
const user = userEvent.setup()
|
| 211 |
render(<App />)
|
| 212 |
|
|
@@ -218,23 +277,34 @@ describe('App Integration', () => {
|
|
| 218 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 219 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 220 |
|
| 221 |
-
// Wait for first segmentation to complete
|
| 222 |
-
await waitFor(
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
|
| 231 |
// Second case
|
| 232 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
|
| 233 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
})
|
| 239 |
})
|
| 240 |
})
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
import { render, screen, waitFor } from '@testing-library/react'
|
| 3 |
import userEvent from '@testing-library/user-event'
|
| 4 |
import { server } from './mocks/server'
|
| 5 |
+
import { errorHandlers, setMockJobDuration } from './mocks/handlers'
|
| 6 |
import App from './App'
|
| 7 |
|
| 8 |
// Mock NiiVue to avoid WebGL in tests
|
|
|
|
| 19 |
}))
|
| 20 |
|
| 21 |
describe('App Integration', () => {
|
| 22 |
+
// Use real timers for integration tests - fake timers don't sync well
|
| 23 |
+
// with MSW's async handlers and polling intervals
|
| 24 |
+
beforeEach(() => {
|
| 25 |
+
// Reset mock job duration to fast for tests
|
| 26 |
+
setMockJobDuration(500) // Jobs complete in 500ms
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
afterEach(() => {
|
| 30 |
+
setMockJobDuration(500) // Reset to default
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
describe('Initial Render', () => {
|
| 34 |
it('renders main heading', () => {
|
| 35 |
render(<App />)
|
|
|
|
| 105 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 106 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 107 |
|
| 108 |
+
// Button should show "Processing..." while job is running
|
| 109 |
+
expect(screen.getByRole('button', { name: /processing/i })).toBeInTheDocument()
|
| 110 |
})
|
| 111 |
|
| 112 |
+
it('shows progress indicator during job execution', async () => {
|
| 113 |
const user = userEvent.setup()
|
| 114 |
render(<App />)
|
| 115 |
|
|
|
|
| 120 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 121 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 122 |
|
| 123 |
+
// Progress indicator should appear during processing
|
| 124 |
+
await waitFor(() => {
|
| 125 |
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
| 126 |
+
})
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
it('displays metrics after successful segmentation', async () => {
|
| 130 |
+
const user = userEvent.setup()
|
| 131 |
+
render(<App />)
|
| 132 |
+
|
| 133 |
await waitFor(() => {
|
| 134 |
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
| 135 |
})
|
| 136 |
|
| 137 |
+
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 138 |
+
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 139 |
+
|
| 140 |
+
// Wait for job to complete (mock duration is 500ms, polling is 2s)
|
| 141 |
+
// Use 5s timeout to account for polling interval
|
| 142 |
+
await waitFor(
|
| 143 |
+
() => {
|
| 144 |
+
expect(screen.getByText('0.847')).toBeInTheDocument()
|
| 145 |
+
},
|
| 146 |
+
{ timeout: 5000 }
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
expect(screen.getByText('15.32 mL')).toBeInTheDocument()
|
|
|
|
| 150 |
})
|
| 151 |
|
| 152 |
it('displays viewer after successful segmentation', async () => {
|
|
|
|
| 160 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 161 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 162 |
|
| 163 |
+
// Wait for job to complete and canvas to render
|
| 164 |
+
await waitFor(
|
| 165 |
+
() => {
|
| 166 |
+
expect(document.querySelector('canvas')).toBeInTheDocument()
|
| 167 |
+
},
|
| 168 |
+
{ timeout: 5000 }
|
| 169 |
+
)
|
| 170 |
})
|
| 171 |
|
| 172 |
it('hides placeholder after successful segmentation', async () => {
|
|
|
|
| 180 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 181 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 182 |
|
| 183 |
+
// Wait for job to complete
|
| 184 |
+
await waitFor(
|
| 185 |
+
() => {
|
| 186 |
+
expect(screen.getByText('0.847')).toBeInTheDocument()
|
| 187 |
+
},
|
| 188 |
+
{ timeout: 5000 }
|
| 189 |
+
)
|
| 190 |
|
| 191 |
expect(
|
| 192 |
screen.queryByText(/select a case and run segmentation/i)
|
| 193 |
).not.toBeInTheDocument()
|
| 194 |
})
|
| 195 |
+
|
| 196 |
+
it('shows cancel button during processing', async () => {
|
| 197 |
+
const user = userEvent.setup()
|
| 198 |
+
render(<App />)
|
| 199 |
+
|
| 200 |
+
await waitFor(() => {
|
| 201 |
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 205 |
+
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 206 |
+
|
| 207 |
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
| 208 |
+
})
|
| 209 |
})
|
| 210 |
|
| 211 |
describe('Error Handling', () => {
|
| 212 |
+
it('shows error when job creation fails', async () => {
|
| 213 |
+
server.use(errorHandlers.segmentCreateError)
|
| 214 |
const user = userEvent.setup()
|
| 215 |
|
| 216 |
render(<App />)
|
|
|
|
| 226 |
expect(screen.getByRole('alert')).toBeInTheDocument()
|
| 227 |
})
|
| 228 |
|
| 229 |
+
expect(screen.getByText(/failed to create job/i)).toBeInTheDocument()
|
| 230 |
})
|
| 231 |
|
| 232 |
it('allows retry after error', async () => {
|
| 233 |
+
server.use(errorHandlers.segmentCreateError)
|
| 234 |
const user = userEvent.setup()
|
| 235 |
|
| 236 |
render(<App />)
|
|
|
|
| 252 |
// Retry
|
| 253 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 254 |
|
| 255 |
+
// Wait for job to complete (real timer now)
|
| 256 |
+
await waitFor(
|
| 257 |
+
() => {
|
| 258 |
+
expect(screen.getByText('0.847')).toBeInTheDocument()
|
| 259 |
+
},
|
| 260 |
+
{ timeout: 5000 }
|
| 261 |
+
)
|
| 262 |
|
| 263 |
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
| 264 |
})
|
| 265 |
})
|
| 266 |
|
| 267 |
describe('Multiple Runs', () => {
|
| 268 |
+
it('allows running segmentation on different cases', { timeout: 15000 }, async () => {
|
| 269 |
const user = userEvent.setup()
|
| 270 |
render(<App />)
|
| 271 |
|
|
|
|
| 277 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
|
| 278 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 279 |
|
| 280 |
+
// Wait for first segmentation to complete - check metrics (Dice Score proves completion)
|
| 281 |
+
await waitFor(
|
| 282 |
+
() => {
|
| 283 |
+
expect(screen.getByText('0.847')).toBeInTheDocument()
|
| 284 |
+
// Button should no longer say "Processing..." after completion
|
| 285 |
+
expect(screen.queryByRole('button', { name: /processing/i })).not.toBeInTheDocument()
|
| 286 |
+
},
|
| 287 |
+
{ timeout: 5000 }
|
| 288 |
+
)
|
| 289 |
|
| 290 |
// Second case
|
| 291 |
await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
|
| 292 |
await user.click(screen.getByRole('button', { name: /run segmentation/i }))
|
| 293 |
|
| 294 |
+
// Wait for second job to complete - check that case ID changed in metrics
|
| 295 |
+
// Note: We look within the metrics container for the case ID to avoid matching dropdown
|
| 296 |
+
await waitFor(
|
| 297 |
+
() => {
|
| 298 |
+
// The metrics panel shows case ID in a span with class "ml-2 font-mono"
|
| 299 |
+
// after the "Case:" label
|
| 300 |
+
const caseLabels = screen.getAllByText(/Case:/i)
|
| 301 |
+
expect(caseLabels.length).toBeGreaterThan(0)
|
| 302 |
+
// The second run should show sub-stroke0002 in the metrics
|
| 303 |
+
const metricsContainer = screen.getByText('Results').closest('div')
|
| 304 |
+
expect(metricsContainer).toHaveTextContent('sub-stroke0002')
|
| 305 |
+
},
|
| 306 |
+
{ timeout: 5000 }
|
| 307 |
+
)
|
| 308 |
})
|
| 309 |
})
|
| 310 |
})
|
|
@@ -3,11 +3,22 @@ import { Layout } from './components/Layout'
|
|
| 3 |
import { CaseSelector } from './components/CaseSelector'
|
| 4 |
import { NiiVueViewer } from './components/NiiVueViewer'
|
| 5 |
import { MetricsPanel } from './components/MetricsPanel'
|
|
|
|
| 6 |
import { useSegmentation } from './hooks/useSegmentation'
|
| 7 |
|
| 8 |
export default function App() {
|
| 9 |
const [selectedCase, setSelectedCase] = useState<string | null>(null)
|
| 10 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
const handleRunSegmentation = async () => {
|
| 13 |
if (selectedCase) {
|
|
@@ -15,6 +26,9 @@ export default function App() {
|
|
| 15 |
}
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
return (
|
| 19 |
<Layout>
|
| 20 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
@@ -35,12 +49,39 @@ export default function App() {
|
|
| 35 |
{isLoading ? 'Processing...' : 'Run Segmentation'}
|
| 36 |
</button>
|
| 37 |
|
| 38 |
-
{
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
)}
|
| 43 |
|
|
|
|
| 44 |
{result && <MetricsPanel metrics={result.metrics} />}
|
| 45 |
</div>
|
| 46 |
|
|
@@ -54,7 +95,9 @@ export default function App() {
|
|
| 54 |
) : (
|
| 55 |
<div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
|
| 56 |
<p className="text-gray-400">
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
</p>
|
| 59 |
</div>
|
| 60 |
)}
|
|
|
|
| 3 |
import { CaseSelector } from './components/CaseSelector'
|
| 4 |
import { NiiVueViewer } from './components/NiiVueViewer'
|
| 5 |
import { MetricsPanel } from './components/MetricsPanel'
|
| 6 |
+
import { ProgressIndicator } from './components/ProgressIndicator'
|
| 7 |
import { useSegmentation } from './hooks/useSegmentation'
|
| 8 |
|
| 9 |
export default function App() {
|
| 10 |
const [selectedCase, setSelectedCase] = useState<string | null>(null)
|
| 11 |
+
const {
|
| 12 |
+
result,
|
| 13 |
+
isLoading,
|
| 14 |
+
error,
|
| 15 |
+
jobStatus,
|
| 16 |
+
progress,
|
| 17 |
+
progressMessage,
|
| 18 |
+
elapsedSeconds,
|
| 19 |
+
runSegmentation,
|
| 20 |
+
cancelJob,
|
| 21 |
+
} = useSegmentation()
|
| 22 |
|
| 23 |
const handleRunSegmentation = async () => {
|
| 24 |
if (selectedCase) {
|
|
|
|
| 26 |
}
|
| 27 |
}
|
| 28 |
|
| 29 |
+
// Show progress indicator when job is active
|
| 30 |
+
const showProgress = isLoading && jobStatus && jobStatus !== 'completed'
|
| 31 |
+
|
| 32 |
return (
|
| 33 |
<Layout>
|
| 34 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
| 49 |
{isLoading ? 'Processing...' : 'Run Segmentation'}
|
| 50 |
</button>
|
| 51 |
|
| 52 |
+
{/* Cancel button when processing */}
|
| 53 |
+
{isLoading && (
|
| 54 |
+
<button
|
| 55 |
+
onClick={cancelJob}
|
| 56 |
+
className="w-full bg-gray-700 hover:bg-gray-600 text-gray-300
|
| 57 |
+
font-medium py-2 px-4 rounded-lg transition-colors text-sm"
|
| 58 |
+
>
|
| 59 |
+
Cancel
|
| 60 |
+
</button>
|
| 61 |
+
)}
|
| 62 |
+
|
| 63 |
+
{/* Progress indicator */}
|
| 64 |
+
{showProgress && (
|
| 65 |
+
<ProgressIndicator
|
| 66 |
+
progress={progress}
|
| 67 |
+
message={progressMessage}
|
| 68 |
+
status={jobStatus}
|
| 69 |
+
elapsedSeconds={elapsedSeconds}
|
| 70 |
+
/>
|
| 71 |
+
)}
|
| 72 |
+
|
| 73 |
+
{/* Error display */}
|
| 74 |
+
{error && !isLoading && (
|
| 75 |
+
<div
|
| 76 |
+
role="alert"
|
| 77 |
+
className="bg-red-900/50 text-red-300 p-3 rounded-lg text-sm"
|
| 78 |
+
>
|
| 79 |
+
<p className="font-medium">Error</p>
|
| 80 |
+
<p className="mt-1">{error}</p>
|
| 81 |
</div>
|
| 82 |
)}
|
| 83 |
|
| 84 |
+
{/* Results metrics */}
|
| 85 |
{result && <MetricsPanel metrics={result.metrics} />}
|
| 86 |
</div>
|
| 87 |
|
|
|
|
| 95 |
) : (
|
| 96 |
<div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
|
| 97 |
<p className="text-gray-400">
|
| 98 |
+
{isLoading
|
| 99 |
+
? 'Processing segmentation...'
|
| 100 |
+
: 'Select a case and run segmentation to view results'}
|
| 101 |
</p>
|
| 102 |
</div>
|
| 103 |
)}
|
|
@@ -25,37 +25,52 @@ describe('apiClient', () => {
|
|
| 25 |
})
|
| 26 |
})
|
| 27 |
|
| 28 |
-
describe('
|
| 29 |
-
it('returns
|
| 30 |
-
const result = await apiClient.
|
| 31 |
|
| 32 |
-
expect(result.
|
| 33 |
-
expect(result.
|
| 34 |
-
expect(result.
|
| 35 |
-
expect(result.dwiUrl).toContain('dwi.nii.gz')
|
| 36 |
-
expect(result.predictionUrl).toContain('prediction.nii.gz')
|
| 37 |
})
|
| 38 |
|
| 39 |
-
it('sends fast_mode
|
| 40 |
-
const result = await apiClient.
|
| 41 |
|
| 42 |
-
|
| 43 |
-
expect(result.
|
| 44 |
})
|
| 45 |
|
| 46 |
-
it('
|
| 47 |
-
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
})
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
await expect(
|
| 57 |
-
apiClient.
|
| 58 |
-
).rejects.toThrow(/
|
| 59 |
})
|
| 60 |
})
|
| 61 |
})
|
|
|
|
| 25 |
})
|
| 26 |
})
|
| 27 |
|
| 28 |
+
describe('createSegmentJob', () => {
|
| 29 |
+
it('returns job ID and pending status', async () => {
|
| 30 |
+
const result = await apiClient.createSegmentJob('sub-stroke0001')
|
| 31 |
|
| 32 |
+
expect(result.jobId).toBeDefined()
|
| 33 |
+
expect(result.status).toBe('pending')
|
| 34 |
+
expect(result.message).toContain('sub-stroke0001')
|
|
|
|
|
|
|
| 35 |
})
|
| 36 |
|
| 37 |
+
it('sends fast_mode parameter', async () => {
|
| 38 |
+
const result = await apiClient.createSegmentJob('sub-stroke0001', false)
|
| 39 |
|
| 40 |
+
expect(result.jobId).toBeDefined()
|
| 41 |
+
expect(result.status).toBe('pending')
|
| 42 |
})
|
| 43 |
|
| 44 |
+
it('throws ApiError on server error', async () => {
|
| 45 |
+
server.use(errorHandlers.segmentCreateError)
|
| 46 |
|
| 47 |
+
await expect(
|
| 48 |
+
apiClient.createSegmentJob('sub-stroke0001')
|
| 49 |
+
).rejects.toThrow(/failed to create job/i)
|
| 50 |
})
|
| 51 |
+
})
|
| 52 |
|
| 53 |
+
describe('getJobStatus', () => {
|
| 54 |
+
it('returns job status with progress', async () => {
|
| 55 |
+
// First create a job
|
| 56 |
+
const createResult = await apiClient.createSegmentJob('sub-stroke0001')
|
| 57 |
+
|
| 58 |
+
// Then get its status
|
| 59 |
+
const status = await apiClient.getJobStatus(createResult.jobId)
|
| 60 |
+
|
| 61 |
+
expect(status.jobId).toBe(createResult.jobId)
|
| 62 |
+
expect(['pending', 'running', 'completed']).toContain(status.status)
|
| 63 |
+
expect(status.progress).toBeGreaterThanOrEqual(0)
|
| 64 |
+
expect(status.progress).toBeLessThanOrEqual(100)
|
| 65 |
+
expect(status.progressMessage).toBeDefined()
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('throws ApiError when job not found', async () => {
|
| 69 |
+
server.use(errorHandlers.jobNotFound)
|
| 70 |
|
| 71 |
await expect(
|
| 72 |
+
apiClient.getJobStatus('nonexistent-job')
|
| 73 |
+
).rejects.toThrow(/not found/i)
|
| 74 |
})
|
| 75 |
})
|
| 76 |
})
|
|
@@ -1,4 +1,8 @@
|
|
| 1 |
-
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
function getApiBase(): string {
|
| 4 |
const url = import.meta.env.VITE_API_URL
|
|
@@ -36,6 +40,9 @@ class ApiClient {
|
|
| 36 |
this.baseUrl = baseUrl
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
async getCases(signal?: AbortSignal): Promise<CasesResponse> {
|
| 40 |
const response = await fetch(`${this.baseUrl}/api/cases`, { signal })
|
| 41 |
|
|
@@ -51,11 +58,17 @@ class ApiClient {
|
|
| 51 |
return response.json()
|
| 52 |
}
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
caseId: string,
|
| 56 |
fastMode: boolean = true,
|
| 57 |
signal?: AbortSignal
|
| 58 |
-
): Promise<
|
| 59 |
const response = await fetch(`${this.baseUrl}/api/segment`, {
|
| 60 |
method: 'POST',
|
| 61 |
headers: {
|
|
@@ -71,7 +84,42 @@ class ApiClient {
|
|
| 71 |
if (!response.ok) {
|
| 72 |
const error = await response.json().catch(() => ({}))
|
| 73 |
throw new ApiError(
|
| 74 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
response.status,
|
| 76 |
error.detail
|
| 77 |
)
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
CasesResponse,
|
| 3 |
+
CreateJobResponse,
|
| 4 |
+
JobStatusResponse,
|
| 5 |
+
} from '../types'
|
| 6 |
|
| 7 |
function getApiBase(): string {
|
| 8 |
const url = import.meta.env.VITE_API_URL
|
|
|
|
| 40 |
this.baseUrl = baseUrl
|
| 41 |
}
|
| 42 |
|
| 43 |
+
/**
|
| 44 |
+
* Get list of available cases
|
| 45 |
+
*/
|
| 46 |
async getCases(signal?: AbortSignal): Promise<CasesResponse> {
|
| 47 |
const response = await fetch(`${this.baseUrl}/api/cases`, { signal })
|
| 48 |
|
|
|
|
| 58 |
return response.json()
|
| 59 |
}
|
| 60 |
|
| 61 |
+
/**
|
| 62 |
+
* Create a segmentation job (async - returns immediately with job ID)
|
| 63 |
+
*
|
| 64 |
+
* The actual ML inference runs in the background. Poll getJobStatus()
|
| 65 |
+
* to track progress and retrieve results when complete.
|
| 66 |
+
*/
|
| 67 |
+
async createSegmentJob(
|
| 68 |
caseId: string,
|
| 69 |
fastMode: boolean = true,
|
| 70 |
signal?: AbortSignal
|
| 71 |
+
): Promise<CreateJobResponse> {
|
| 72 |
const response = await fetch(`${this.baseUrl}/api/segment`, {
|
| 73 |
method: 'POST',
|
| 74 |
headers: {
|
|
|
|
| 84 |
if (!response.ok) {
|
| 85 |
const error = await response.json().catch(() => ({}))
|
| 86 |
throw new ApiError(
|
| 87 |
+
`Failed to create job: ${error.detail || response.statusText}`,
|
| 88 |
+
response.status,
|
| 89 |
+
error.detail
|
| 90 |
+
)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return response.json()
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Get the status of a segmentation job
|
| 98 |
+
*
|
| 99 |
+
* Poll this endpoint to track progress and retrieve results.
|
| 100 |
+
* When status is 'completed', the result field contains segmentation data.
|
| 101 |
+
* When status is 'failed', the error field contains the error message.
|
| 102 |
+
*/
|
| 103 |
+
async getJobStatus(
|
| 104 |
+
jobId: string,
|
| 105 |
+
signal?: AbortSignal
|
| 106 |
+
): Promise<JobStatusResponse> {
|
| 107 |
+
const response = await fetch(`${this.baseUrl}/api/jobs/${jobId}`, {
|
| 108 |
+
signal,
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
if (response.status === 404) {
|
| 112 |
+
throw new ApiError(
|
| 113 |
+
'Job not found or expired',
|
| 114 |
+
404,
|
| 115 |
+
'Jobs expire after 1 hour'
|
| 116 |
+
)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (!response.ok) {
|
| 120 |
+
const error = await response.json().catch(() => ({}))
|
| 121 |
+
throw new ApiError(
|
| 122 |
+
`Failed to get job status: ${error.detail || response.statusText}`,
|
| 123 |
response.status,
|
| 124 |
error.detail
|
| 125 |
)
|
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { JobStatus } from '../types'
|
| 2 |
+
|
| 3 |
+
interface ProgressIndicatorProps {
|
| 4 |
+
progress: number
|
| 5 |
+
message: string
|
| 6 |
+
status: JobStatus
|
| 7 |
+
elapsedSeconds?: number
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Visual progress indicator for long-running ML inference jobs.
|
| 12 |
+
*
|
| 13 |
+
* Shows:
|
| 14 |
+
* - Progress bar with percentage
|
| 15 |
+
* - Current operation message
|
| 16 |
+
* - Elapsed time
|
| 17 |
+
* - Status-appropriate coloring (blue for running, red for failed)
|
| 18 |
+
*/
|
| 19 |
+
export function ProgressIndicator({
|
| 20 |
+
progress,
|
| 21 |
+
message,
|
| 22 |
+
status,
|
| 23 |
+
elapsedSeconds,
|
| 24 |
+
}: ProgressIndicatorProps) {
|
| 25 |
+
const isError = status === 'failed'
|
| 26 |
+
const isComplete = status === 'completed'
|
| 27 |
+
|
| 28 |
+
// Determine bar color based on status
|
| 29 |
+
const barColorClass = isError
|
| 30 |
+
? 'bg-red-500'
|
| 31 |
+
: isComplete
|
| 32 |
+
? 'bg-green-500'
|
| 33 |
+
: 'bg-blue-500'
|
| 34 |
+
|
| 35 |
+
// Animate the bar while running
|
| 36 |
+
const animationClass =
|
| 37 |
+
status === 'running' || status === 'pending' ? 'animate-pulse' : ''
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="bg-gray-800 rounded-lg p-4 space-y-3">
|
| 41 |
+
{/* Header with message and percentage */}
|
| 42 |
+
<div className="flex justify-between items-center text-sm">
|
| 43 |
+
<span className="text-gray-300 font-medium">{message}</span>
|
| 44 |
+
<span className="text-gray-400 tabular-nums">{progress}%</span>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Progress bar */}
|
| 48 |
+
<div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
| 49 |
+
<div
|
| 50 |
+
className={`h-full rounded-full transition-all duration-500 ease-out ${barColorClass} ${animationClass}`}
|
| 51 |
+
style={{ width: `${progress}%` }}
|
| 52 |
+
role="progressbar"
|
| 53 |
+
aria-valuenow={progress}
|
| 54 |
+
aria-valuemin={0}
|
| 55 |
+
aria-valuemax={100}
|
| 56 |
+
aria-label={message}
|
| 57 |
+
/>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{/* Footer with elapsed time and status */}
|
| 61 |
+
<div className="flex justify-between items-center text-xs text-gray-500">
|
| 62 |
+
{elapsedSeconds !== undefined ? (
|
| 63 |
+
<span className="tabular-nums">
|
| 64 |
+
Elapsed: {elapsedSeconds.toFixed(1)}s
|
| 65 |
+
</span>
|
| 66 |
+
) : (
|
| 67 |
+
<span>Starting...</span>
|
| 68 |
+
)}
|
| 69 |
+
|
| 70 |
+
<span
|
| 71 |
+
className={`capitalize ${
|
| 72 |
+
isError
|
| 73 |
+
? 'text-red-400'
|
| 74 |
+
: isComplete
|
| 75 |
+
? 'text-green-400'
|
| 76 |
+
: 'text-blue-400'
|
| 77 |
+
}`}
|
| 78 |
+
>
|
| 79 |
+
{status}
|
| 80 |
+
</span>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
}
|
|
@@ -2,3 +2,4 @@ export { Layout } from './Layout'
|
|
| 2 |
export { MetricsPanel } from './MetricsPanel'
|
| 3 |
export { CaseSelector } from './CaseSelector'
|
| 4 |
export { NiiVueViewer } from './NiiVueViewer'
|
|
|
|
|
|
| 2 |
export { MetricsPanel } from './MetricsPanel'
|
| 3 |
export { CaseSelector } from './CaseSelector'
|
| 4 |
export { NiiVueViewer } from './NiiVueViewer'
|
| 5 |
+
export { ProgressIndicator } from './ProgressIndicator'
|
|
@@ -1,19 +1,28 @@
|
|
| 1 |
-
import { describe, it, expect } from 'vitest'
|
| 2 |
import { renderHook, waitFor, act } from '@testing-library/react'
|
| 3 |
import { server } from '../../mocks/server'
|
| 4 |
import { errorHandlers } from '../../mocks/handlers'
|
| 5 |
import { useSegmentation } from '../useSegmentation'
|
| 6 |
|
| 7 |
describe('useSegmentation', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
it('starts with null result and not loading', () => {
|
| 9 |
const { result } = renderHook(() => useSegmentation())
|
| 10 |
|
| 11 |
expect(result.current.result).toBeNull()
|
| 12 |
expect(result.current.isLoading).toBe(false)
|
| 13 |
expect(result.current.error).toBeNull()
|
|
|
|
| 14 |
})
|
| 15 |
|
| 16 |
-
it('sets loading state during segmentation', async () => {
|
| 17 |
const { result } = renderHook(() => useSegmentation())
|
| 18 |
|
| 19 |
act(() => {
|
|
@@ -22,75 +31,132 @@ describe('useSegmentation', () => {
|
|
| 22 |
|
| 23 |
expect(result.current.isLoading).toBe(true)
|
| 24 |
|
|
|
|
| 25 |
await waitFor(() => {
|
| 26 |
-
expect(result.current.
|
| 27 |
})
|
|
|
|
|
|
|
| 28 |
})
|
| 29 |
|
| 30 |
-
it('returns result on
|
| 31 |
const { result } = renderHook(() => useSegmentation())
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
await act(async () => {
|
| 34 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
})
|
| 36 |
|
| 37 |
-
expect(result.current.result).not.toBeNull()
|
| 38 |
expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
|
| 39 |
expect(result.current.result?.metrics.diceScore).toBe(0.847)
|
| 40 |
expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
|
| 41 |
})
|
| 42 |
|
| 43 |
-
it('
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
const { result } = renderHook(() => useSegmentation())
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
})
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
expect(result.current.result).toBeNull()
|
| 54 |
})
|
| 55 |
|
| 56 |
it('clears previous error on new request', async () => {
|
| 57 |
-
server.use(errorHandlers.
|
| 58 |
const { result } = renderHook(() => useSegmentation())
|
| 59 |
|
| 60 |
// First request fails
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
})
|
| 64 |
-
expect(result.current.error).not.toBeNull()
|
| 65 |
|
| 66 |
// Reset to success handler
|
| 67 |
server.resetHandlers()
|
| 68 |
|
| 69 |
-
// Second request
|
| 70 |
-
|
| 71 |
-
|
| 72 |
})
|
| 73 |
|
| 74 |
expect(result.current.error).toBeNull()
|
| 75 |
-
expect(result.current.
|
| 76 |
})
|
| 77 |
|
| 78 |
-
it('
|
| 79 |
const { result } = renderHook(() => useSegmentation())
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
await result.current.runSegmentation('sub-stroke0001')
|
| 84 |
})
|
| 85 |
-
expect(result.current.result).not.toBeNull()
|
| 86 |
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
act(() => {
|
| 89 |
-
result.current.
|
| 90 |
})
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
})
|
| 96 |
})
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
import { renderHook, waitFor, act } from '@testing-library/react'
|
| 3 |
import { server } from '../../mocks/server'
|
| 4 |
import { errorHandlers } from '../../mocks/handlers'
|
| 5 |
import { useSegmentation } from '../useSegmentation'
|
| 6 |
|
| 7 |
describe('useSegmentation', () => {
|
| 8 |
+
beforeEach(() => {
|
| 9 |
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
| 10 |
+
})
|
| 11 |
+
|
| 12 |
+
afterEach(() => {
|
| 13 |
+
vi.useRealTimers()
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
it('starts with null result and not loading', () => {
|
| 17 |
const { result } = renderHook(() => useSegmentation())
|
| 18 |
|
| 19 |
expect(result.current.result).toBeNull()
|
| 20 |
expect(result.current.isLoading).toBe(false)
|
| 21 |
expect(result.current.error).toBeNull()
|
| 22 |
+
expect(result.current.jobStatus).toBeNull()
|
| 23 |
})
|
| 24 |
|
| 25 |
+
it('sets loading state and job status during segmentation', async () => {
|
| 26 |
const { result } = renderHook(() => useSegmentation())
|
| 27 |
|
| 28 |
act(() => {
|
|
|
|
| 31 |
|
| 32 |
expect(result.current.isLoading).toBe(true)
|
| 33 |
|
| 34 |
+
// Wait for job to be created
|
| 35 |
await waitFor(() => {
|
| 36 |
+
expect(result.current.jobId).toBeDefined()
|
| 37 |
})
|
| 38 |
+
|
| 39 |
+
expect(result.current.jobStatus).toBeDefined()
|
| 40 |
})
|
| 41 |
|
| 42 |
+
it('returns result on job completion', async () => {
|
| 43 |
const { result } = renderHook(() => useSegmentation())
|
| 44 |
|
| 45 |
+
act(() => {
|
| 46 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
// Wait for job creation
|
| 50 |
+
await waitFor(() => {
|
| 51 |
+
expect(result.current.jobId).toBeDefined()
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
// Advance time to allow job to complete (mock jobs complete in ~3s)
|
| 55 |
await act(async () => {
|
| 56 |
+
await vi.advanceTimersByTimeAsync(5000)
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
await waitFor(() => {
|
| 60 |
+
expect(result.current.isLoading).toBe(false)
|
| 61 |
+
expect(result.current.result).not.toBeNull()
|
| 62 |
})
|
| 63 |
|
|
|
|
| 64 |
expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
|
| 65 |
expect(result.current.result?.metrics.diceScore).toBe(0.847)
|
| 66 |
expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
|
| 67 |
})
|
| 68 |
|
| 69 |
+
it('shows progress updates during job execution', async () => {
|
| 70 |
+
const { result } = renderHook(() => useSegmentation())
|
| 71 |
+
|
| 72 |
+
act(() => {
|
| 73 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
// Wait for job to start
|
| 77 |
+
await waitFor(() => {
|
| 78 |
+
expect(result.current.jobId).toBeDefined()
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
// Progress should be tracked
|
| 82 |
+
expect(result.current.progress).toBeGreaterThanOrEqual(0)
|
| 83 |
+
expect(result.current.progressMessage).toBeDefined()
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
it('sets error on job creation failure', async () => {
|
| 87 |
+
server.use(errorHandlers.segmentCreateError)
|
| 88 |
|
| 89 |
const { result } = renderHook(() => useSegmentation())
|
| 90 |
|
| 91 |
+
act(() => {
|
| 92 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 93 |
})
|
| 94 |
|
| 95 |
+
await waitFor(() => {
|
| 96 |
+
expect(result.current.isLoading).toBe(false)
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
expect(result.current.error).toMatch(/failed to create job/i)
|
| 100 |
expect(result.current.result).toBeNull()
|
| 101 |
})
|
| 102 |
|
| 103 |
it('clears previous error on new request', async () => {
|
| 104 |
+
server.use(errorHandlers.segmentCreateError)
|
| 105 |
const { result } = renderHook(() => useSegmentation())
|
| 106 |
|
| 107 |
// First request fails
|
| 108 |
+
act(() => {
|
| 109 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
await waitFor(() => {
|
| 113 |
+
expect(result.current.error).not.toBeNull()
|
| 114 |
})
|
|
|
|
| 115 |
|
| 116 |
// Reset to success handler
|
| 117 |
server.resetHandlers()
|
| 118 |
|
| 119 |
+
// Second request should clear error
|
| 120 |
+
act(() => {
|
| 121 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 122 |
})
|
| 123 |
|
| 124 |
expect(result.current.error).toBeNull()
|
| 125 |
+
expect(result.current.isLoading).toBe(true)
|
| 126 |
})
|
| 127 |
|
| 128 |
+
it('can cancel a running job', async () => {
|
| 129 |
const { result } = renderHook(() => useSegmentation())
|
| 130 |
|
| 131 |
+
act(() => {
|
| 132 |
+
result.current.runSegmentation('sub-stroke0001')
|
|
|
|
| 133 |
})
|
|
|
|
| 134 |
|
| 135 |
+
await waitFor(() => {
|
| 136 |
+
expect(result.current.isLoading).toBe(true)
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
// Cancel the job
|
| 140 |
act(() => {
|
| 141 |
+
result.current.cancelJob()
|
| 142 |
})
|
| 143 |
|
| 144 |
+
expect(result.current.isLoading).toBe(false)
|
| 145 |
+
expect(result.current.jobStatus).toBeNull()
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
it('cleans up polling on unmount', async () => {
|
| 149 |
+
const { result, unmount } = renderHook(() => useSegmentation())
|
| 150 |
+
|
| 151 |
+
act(() => {
|
| 152 |
+
result.current.runSegmentation('sub-stroke0001')
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
await waitFor(() => {
|
| 156 |
+
expect(result.current.isLoading).toBe(true)
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
// Unmount should not throw
|
| 160 |
+
unmount()
|
| 161 |
})
|
| 162 |
})
|
|
@@ -1,63 +1,205 @@
|
|
| 1 |
-
import { useState, useCallback, useRef } from 'react'
|
| 2 |
import { apiClient } from '../api/client'
|
| 3 |
-
import type { SegmentationResult } from '../types'
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export function useSegmentation() {
|
|
|
|
| 6 |
const [result, setResult] = useState<SegmentationResult | null>(null)
|
| 7 |
-
const [isLoading, setIsLoading] = useState(false)
|
| 8 |
const [error, setError] = useState<string | null>(null)
|
| 9 |
|
| 10 |
-
//
|
| 11 |
-
|
| 12 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
const abortControllerRef = useRef<AbortController | null>(null)
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
//
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
setResult(null)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
setIsLoading(false)
|
|
|
|
| 58 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
-
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
| 2 |
import { apiClient } from '../api/client'
|
| 3 |
+
import type { SegmentationResult, JobStatus } from '../types'
|
| 4 |
|
| 5 |
+
// Polling interval in milliseconds
|
| 6 |
+
const POLLING_INTERVAL = 2000
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Hook for running segmentation with async job polling.
|
| 10 |
+
*
|
| 11 |
+
* Instead of waiting for the full inference to complete (which can timeout
|
| 12 |
+
* on HuggingFace Spaces), this hook:
|
| 13 |
+
* 1. Creates a job that returns immediately with a job ID
|
| 14 |
+
* 2. Polls for job status/progress every 2 seconds
|
| 15 |
+
* 3. Returns results when the job completes
|
| 16 |
+
*
|
| 17 |
+
* This avoids the ~60s gateway timeout on HF Spaces while providing
|
| 18 |
+
* real-time progress updates to the user.
|
| 19 |
+
*/
|
| 20 |
export function useSegmentation() {
|
| 21 |
+
// Result state
|
| 22 |
const [result, setResult] = useState<SegmentationResult | null>(null)
|
|
|
|
| 23 |
const [error, setError] = useState<string | null>(null)
|
| 24 |
|
| 25 |
+
// Job tracking state
|
| 26 |
+
const [jobId, setJobId] = useState<string | null>(null)
|
| 27 |
+
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null)
|
| 28 |
+
const [progress, setProgress] = useState(0)
|
| 29 |
+
const [progressMessage, setProgressMessage] = useState('')
|
| 30 |
+
const [elapsedSeconds, setElapsedSeconds] = useState<number | undefined>(
|
| 31 |
+
undefined
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
// Loading state - true from job creation until completion/failure
|
| 35 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 36 |
+
|
| 37 |
+
// Refs for managing async operations
|
| 38 |
+
const currentJobRef = useRef<string | null>(null)
|
| 39 |
+
const pollingIntervalRef = useRef<number | null>(null)
|
| 40 |
const abortControllerRef = useRef<AbortController | null>(null)
|
| 41 |
|
| 42 |
+
/**
|
| 43 |
+
* Stop polling for job status
|
| 44 |
+
*/
|
| 45 |
+
const stopPolling = useCallback(() => {
|
| 46 |
+
if (pollingIntervalRef.current) {
|
| 47 |
+
clearInterval(pollingIntervalRef.current)
|
| 48 |
+
pollingIntervalRef.current = null
|
| 49 |
+
}
|
| 50 |
+
}, [])
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Poll for job status and update state
|
| 54 |
+
*/
|
| 55 |
+
const pollJobStatus = useCallback(
|
| 56 |
+
async (id: string, signal: AbortSignal) => {
|
| 57 |
+
// Don't poll if this isn't the current job
|
| 58 |
+
if (id !== currentJobRef.current) {
|
| 59 |
+
stopPolling()
|
| 60 |
+
return
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
try {
|
| 64 |
+
const response = await apiClient.getJobStatus(id, signal)
|
| 65 |
+
|
| 66 |
+
// Ignore results if job changed
|
| 67 |
+
if (id !== currentJobRef.current) return
|
| 68 |
+
|
| 69 |
+
// Update progress state
|
| 70 |
+
setJobStatus(response.status)
|
| 71 |
+
setProgress(response.progress)
|
| 72 |
+
setProgressMessage(response.progressMessage)
|
| 73 |
+
setElapsedSeconds(response.elapsedSeconds)
|
| 74 |
+
|
| 75 |
+
// Handle completion
|
| 76 |
+
if (response.status === 'completed' && response.result) {
|
| 77 |
+
stopPolling()
|
| 78 |
+
setIsLoading(false)
|
| 79 |
+
setResult({
|
| 80 |
+
dwiUrl: response.result.dwiUrl,
|
| 81 |
+
predictionUrl: response.result.predictionUrl,
|
| 82 |
+
metrics: {
|
| 83 |
+
caseId: response.result.caseId,
|
| 84 |
+
diceScore: response.result.diceScore,
|
| 85 |
+
volumeMl: response.result.volumeMl,
|
| 86 |
+
elapsedSeconds: response.result.elapsedSeconds,
|
| 87 |
+
},
|
| 88 |
+
})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Handle failure
|
| 92 |
+
if (response.status === 'failed') {
|
| 93 |
+
stopPolling()
|
| 94 |
+
setIsLoading(false)
|
| 95 |
+
setError(response.error || 'Job failed')
|
| 96 |
+
setResult(null)
|
| 97 |
+
}
|
| 98 |
+
} catch (err) {
|
| 99 |
+
// Ignore abort errors
|
| 100 |
+
if (err instanceof Error && err.name === 'AbortError') return
|
| 101 |
+
|
| 102 |
+
// Don't stop polling on transient network errors - retry next interval
|
| 103 |
+
console.warn('Polling error (will retry):', err)
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
[stopPolling]
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Start segmentation job and begin polling
|
| 111 |
+
*/
|
| 112 |
+
const runSegmentation = useCallback(
|
| 113 |
+
async (caseId: string, fastMode = true) => {
|
| 114 |
+
// Cancel any existing job/polling
|
| 115 |
+
stopPolling()
|
| 116 |
+
abortControllerRef.current?.abort()
|
| 117 |
+
|
| 118 |
+
const abortController = new AbortController()
|
| 119 |
+
abortControllerRef.current = abortController
|
| 120 |
+
|
| 121 |
+
// Reset state
|
| 122 |
+
setError(null)
|
| 123 |
setResult(null)
|
| 124 |
+
setProgress(0)
|
| 125 |
+
setProgressMessage('Creating job...')
|
| 126 |
+
setJobStatus('pending')
|
| 127 |
+
setElapsedSeconds(undefined)
|
| 128 |
+
setIsLoading(true)
|
| 129 |
+
|
| 130 |
+
try {
|
| 131 |
+
// Create the job
|
| 132 |
+
const response = await apiClient.createSegmentJob(
|
| 133 |
+
caseId,
|
| 134 |
+
fastMode,
|
| 135 |
+
abortController.signal
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
// Store job reference
|
| 139 |
+
const newJobId = response.jobId
|
| 140 |
+
setJobId(newJobId)
|
| 141 |
+
currentJobRef.current = newJobId
|
| 142 |
+
setJobStatus(response.status)
|
| 143 |
+
setProgressMessage(response.message)
|
| 144 |
+
|
| 145 |
+
// Start polling
|
| 146 |
+
pollingIntervalRef.current = window.setInterval(() => {
|
| 147 |
+
pollJobStatus(newJobId, abortController.signal)
|
| 148 |
+
}, POLLING_INTERVAL)
|
| 149 |
+
|
| 150 |
+
// Do an initial poll immediately
|
| 151 |
+
await pollJobStatus(newJobId, abortController.signal)
|
| 152 |
+
} catch (err) {
|
| 153 |
+
// Ignore abort errors
|
| 154 |
+
if (err instanceof Error && err.name === 'AbortError') return
|
| 155 |
+
|
| 156 |
+
const message = err instanceof Error ? err.message : 'Failed to start job'
|
| 157 |
+
setError(message)
|
| 158 |
setIsLoading(false)
|
| 159 |
+
setJobStatus('failed')
|
| 160 |
}
|
| 161 |
+
},
|
| 162 |
+
[pollJobStatus, stopPolling]
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Cancel the current job (stops polling, clears loading state)
|
| 167 |
+
*/
|
| 168 |
+
const cancelJob = useCallback(() => {
|
| 169 |
+
stopPolling()
|
| 170 |
+
abortControllerRef.current?.abort()
|
| 171 |
+
currentJobRef.current = null
|
| 172 |
+
setIsLoading(false)
|
| 173 |
+
setJobStatus(null)
|
| 174 |
+
setProgress(0)
|
| 175 |
+
setProgressMessage('')
|
| 176 |
+
}, [stopPolling])
|
| 177 |
+
|
| 178 |
+
// Cleanup on unmount
|
| 179 |
+
useEffect(() => {
|
| 180 |
+
return () => {
|
| 181 |
+
stopPolling()
|
| 182 |
+
abortControllerRef.current?.abort()
|
| 183 |
}
|
| 184 |
+
}, [stopPolling])
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
// Result data
|
| 188 |
+
result,
|
| 189 |
+
error,
|
| 190 |
+
|
| 191 |
+
// Job status
|
| 192 |
+
jobId,
|
| 193 |
+
jobStatus,
|
| 194 |
+
progress,
|
| 195 |
+
progressMessage,
|
| 196 |
+
elapsedSeconds,
|
| 197 |
+
|
| 198 |
+
// Loading state
|
| 199 |
+
isLoading,
|
| 200 |
|
| 201 |
+
// Actions
|
| 202 |
+
runSegmentation,
|
| 203 |
+
cancelJob,
|
| 204 |
+
}
|
| 205 |
}
|
|
@@ -1,8 +1,66 @@
|
|
| 1 |
import { http, HttpResponse, delay } from 'msw'
|
|
|
|
| 2 |
|
| 3 |
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export const handlers = [
|
|
|
|
| 6 |
http.get(`${API_BASE}/api/cases`, async () => {
|
| 7 |
await delay(100)
|
| 8 |
return HttpResponse.json({
|
|
@@ -10,18 +68,75 @@ export const handlers = [
|
|
| 10 |
})
|
| 11 |
}),
|
| 12 |
|
|
|
|
| 13 |
http.post(`${API_BASE}/api/segment`, async ({ request }) => {
|
| 14 |
const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
|
| 15 |
-
await delay(
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
caseId: body.case_id,
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
elapsedSeconds:
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}),
|
| 26 |
]
|
| 27 |
|
|
@@ -38,15 +153,54 @@ export const errorHandlers = {
|
|
| 38 |
return HttpResponse.error()
|
| 39 |
}),
|
| 40 |
|
| 41 |
-
|
| 42 |
return HttpResponse.json(
|
| 43 |
-
{ detail: '
|
| 44 |
-
{ status:
|
| 45 |
)
|
| 46 |
}),
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
|
|
|
| 1 |
import { http, HttpResponse, delay } from 'msw'
|
| 2 |
+
import type { JobStatus } from '../types'
|
| 3 |
|
| 4 |
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
|
| 5 |
|
| 6 |
+
// In-memory job store for mocking
|
| 7 |
+
interface MockJob {
|
| 8 |
+
id: string
|
| 9 |
+
caseId: string
|
| 10 |
+
status: JobStatus
|
| 11 |
+
progress: number
|
| 12 |
+
progressMessage: string
|
| 13 |
+
elapsedSeconds: number
|
| 14 |
+
fastMode: boolean
|
| 15 |
+
createdAt: number
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const mockJobs = new Map<string, MockJob>()
|
| 19 |
+
let jobCounter = 0
|
| 20 |
+
|
| 21 |
+
// Configurable job duration for tests (ms)
|
| 22 |
+
// Default: 500ms for fast tests
|
| 23 |
+
let mockJobDurationMs = 500
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Set the mock job duration for tests.
|
| 27 |
+
* Jobs will complete after this many milliseconds.
|
| 28 |
+
*/
|
| 29 |
+
export function setMockJobDuration(durationMs: number): void {
|
| 30 |
+
mockJobDurationMs = durationMs
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Simulate job progression over time
|
| 34 |
+
function getJobProgress(job: MockJob): MockJob {
|
| 35 |
+
const elapsed = (Date.now() - job.createdAt) / 1000
|
| 36 |
+
const duration = mockJobDurationMs / 1000 // Convert to seconds
|
| 37 |
+
|
| 38 |
+
if (job.status === 'completed' || job.status === 'failed') {
|
| 39 |
+
return job
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Progress through stages based on elapsed time relative to configured duration
|
| 43 |
+
// Stages: 20% loading, 40% inference, 30% processing, 10% finalizing
|
| 44 |
+
const progress20 = duration * 0.2
|
| 45 |
+
const progress60 = duration * 0.6
|
| 46 |
+
const progress90 = duration * 0.9
|
| 47 |
+
|
| 48 |
+
if (elapsed < progress20) {
|
| 49 |
+
return { ...job, status: 'running', progress: 10, progressMessage: 'Loading case data...', elapsedSeconds: elapsed }
|
| 50 |
+
} else if (elapsed < progress60) {
|
| 51 |
+
return { ...job, status: 'running', progress: 30, progressMessage: 'Running DeepISLES inference...', elapsedSeconds: elapsed }
|
| 52 |
+
} else if (elapsed < progress90) {
|
| 53 |
+
return { ...job, status: 'running', progress: 70, progressMessage: 'Processing results...', elapsedSeconds: elapsed }
|
| 54 |
+
} else if (elapsed < duration) {
|
| 55 |
+
return { ...job, status: 'running', progress: 90, progressMessage: 'Computing metrics...', elapsedSeconds: elapsed }
|
| 56 |
+
} else {
|
| 57 |
+
// Job complete
|
| 58 |
+
return { ...job, status: 'completed', progress: 100, progressMessage: 'Segmentation complete', elapsedSeconds: elapsed }
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
export const handlers = [
|
| 63 |
+
// GET /api/cases - List available cases
|
| 64 |
http.get(`${API_BASE}/api/cases`, async () => {
|
| 65 |
await delay(100)
|
| 66 |
return HttpResponse.json({
|
|
|
|
| 68 |
})
|
| 69 |
}),
|
| 70 |
|
| 71 |
+
// POST /api/segment - Create segmentation job (returns immediately)
|
| 72 |
http.post(`${API_BASE}/api/segment`, async ({ request }) => {
|
| 73 |
const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
|
| 74 |
+
await delay(50) // Small delay to simulate network
|
| 75 |
+
|
| 76 |
+
// Create a new job
|
| 77 |
+
const jobId = `mock-${++jobCounter}`
|
| 78 |
+
const job: MockJob = {
|
| 79 |
+
id: jobId,
|
| 80 |
caseId: body.case_id,
|
| 81 |
+
status: 'pending',
|
| 82 |
+
progress: 0,
|
| 83 |
+
progressMessage: 'Job queued',
|
| 84 |
+
elapsedSeconds: 0,
|
| 85 |
+
fastMode: body.fast_mode !== false,
|
| 86 |
+
createdAt: Date.now(),
|
| 87 |
+
}
|
| 88 |
+
mockJobs.set(jobId, job)
|
| 89 |
+
|
| 90 |
+
// Return 202 Accepted with job ID
|
| 91 |
+
return HttpResponse.json(
|
| 92 |
+
{
|
| 93 |
+
jobId: jobId,
|
| 94 |
+
status: 'pending',
|
| 95 |
+
message: `Segmentation job queued for ${body.case_id}`,
|
| 96 |
+
},
|
| 97 |
+
{ status: 202 }
|
| 98 |
+
)
|
| 99 |
+
}),
|
| 100 |
+
|
| 101 |
+
// GET /api/jobs/:jobId - Get job status
|
| 102 |
+
http.get(`${API_BASE}/api/jobs/:jobId`, async ({ params }) => {
|
| 103 |
+
const jobId = params.jobId as string
|
| 104 |
+
await delay(50) // Small delay to simulate network
|
| 105 |
+
|
| 106 |
+
const job = mockJobs.get(jobId)
|
| 107 |
+
if (!job) {
|
| 108 |
+
return HttpResponse.json(
|
| 109 |
+
{ detail: `Job not found: ${jobId}. Jobs expire after 1 hour.` },
|
| 110 |
+
{ status: 404 }
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Update job progress based on elapsed time
|
| 115 |
+
const updatedJob = getJobProgress(job)
|
| 116 |
+
mockJobs.set(jobId, updatedJob)
|
| 117 |
+
|
| 118 |
+
// Build response
|
| 119 |
+
const response: Record<string, unknown> = {
|
| 120 |
+
jobId: updatedJob.id,
|
| 121 |
+
status: updatedJob.status,
|
| 122 |
+
progress: updatedJob.progress,
|
| 123 |
+
progressMessage: updatedJob.progressMessage,
|
| 124 |
+
elapsedSeconds: Math.round(updatedJob.elapsedSeconds * 100) / 100,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Include result if completed
|
| 128 |
+
if (updatedJob.status === 'completed') {
|
| 129 |
+
response.result = {
|
| 130 |
+
caseId: updatedJob.caseId,
|
| 131 |
+
diceScore: 0.847,
|
| 132 |
+
volumeMl: 15.32,
|
| 133 |
+
elapsedSeconds: updatedJob.fastMode ? 12.5 : 45.0,
|
| 134 |
+
dwiUrl: `${API_BASE}/files/${jobId}/${updatedJob.caseId}/dwi.nii.gz`,
|
| 135 |
+
predictionUrl: `${API_BASE}/files/${jobId}/${updatedJob.caseId}/prediction.nii.gz`,
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
return HttpResponse.json(response)
|
| 140 |
}),
|
| 141 |
]
|
| 142 |
|
|
|
|
| 153 |
return HttpResponse.error()
|
| 154 |
}),
|
| 155 |
|
| 156 |
+
segmentCreateError: http.post(`${API_BASE}/api/segment`, () => {
|
| 157 |
return HttpResponse.json(
|
| 158 |
+
{ detail: 'Failed to create job: case not found' },
|
| 159 |
+
{ status: 400 }
|
| 160 |
)
|
| 161 |
}),
|
| 162 |
|
| 163 |
+
jobNotFound: http.get(`${API_BASE}/api/jobs/:jobId`, () => {
|
| 164 |
+
return HttpResponse.json(
|
| 165 |
+
{ detail: 'Job not found or expired' },
|
| 166 |
+
{ status: 404 }
|
| 167 |
+
)
|
| 168 |
}),
|
| 169 |
+
|
| 170 |
+
// Simulate a job that fails during processing
|
| 171 |
+
jobFailed: [
|
| 172 |
+
http.post(`${API_BASE}/api/segment`, async ({ request }) => {
|
| 173 |
+
const body = (await request.json()) as { case_id: string }
|
| 174 |
+
const jobId = `fail-${++jobCounter}`
|
| 175 |
+
mockJobs.set(jobId, {
|
| 176 |
+
id: jobId,
|
| 177 |
+
caseId: body.case_id,
|
| 178 |
+
status: 'failed',
|
| 179 |
+
progress: 30,
|
| 180 |
+
progressMessage: 'Error occurred',
|
| 181 |
+
elapsedSeconds: 5.2,
|
| 182 |
+
fastMode: true,
|
| 183 |
+
createdAt: Date.now(),
|
| 184 |
+
})
|
| 185 |
+
return HttpResponse.json(
|
| 186 |
+
{ jobId, status: 'pending', message: 'Job queued' },
|
| 187 |
+
{ status: 202 }
|
| 188 |
+
)
|
| 189 |
+
}),
|
| 190 |
+
http.get(`${API_BASE}/api/jobs/:jobId`, ({ params }) => {
|
| 191 |
+
const jobId = params.jobId as string
|
| 192 |
+
const job = mockJobs.get(jobId)
|
| 193 |
+
if (!job) {
|
| 194 |
+
return HttpResponse.json({ detail: 'Not found' }, { status: 404 })
|
| 195 |
+
}
|
| 196 |
+
return HttpResponse.json({
|
| 197 |
+
jobId: job.id,
|
| 198 |
+
status: 'failed',
|
| 199 |
+
progress: 30,
|
| 200 |
+
progressMessage: 'Error occurred',
|
| 201 |
+
elapsedSeconds: 5.2,
|
| 202 |
+
error: 'Segmentation failed: out of memory',
|
| 203 |
+
})
|
| 204 |
+
}),
|
| 205 |
+
],
|
| 206 |
}
|
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
export interface Metrics {
|
| 2 |
caseId: string
|
| 3 |
diceScore: number | null
|
|
@@ -5,16 +6,19 @@ export interface Metrics {
|
|
| 5 |
elapsedSeconds: number
|
| 6 |
}
|
| 7 |
|
|
|
|
| 8 |
export interface SegmentationResult {
|
| 9 |
dwiUrl: string
|
| 10 |
predictionUrl: string
|
| 11 |
metrics: Metrics
|
| 12 |
}
|
| 13 |
|
|
|
|
| 14 |
export interface CasesResponse {
|
| 15 |
cases: string[]
|
| 16 |
}
|
| 17 |
|
|
|
|
| 18 |
export interface SegmentResponse {
|
| 19 |
caseId: string
|
| 20 |
diceScore: number | null
|
|
@@ -23,3 +27,24 @@ export interface SegmentResponse {
|
|
| 23 |
dwiUrl: string
|
| 24 |
predictionUrl: string
|
| 25 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Segmentation metrics
|
| 2 |
export interface Metrics {
|
| 3 |
caseId: string
|
| 4 |
diceScore: number | null
|
|
|
|
| 6 |
elapsedSeconds: number
|
| 7 |
}
|
| 8 |
|
| 9 |
+
// Final segmentation result with URLs and metrics
|
| 10 |
export interface SegmentationResult {
|
| 11 |
dwiUrl: string
|
| 12 |
predictionUrl: string
|
| 13 |
metrics: Metrics
|
| 14 |
}
|
| 15 |
|
| 16 |
+
// API Response Types
|
| 17 |
export interface CasesResponse {
|
| 18 |
cases: string[]
|
| 19 |
}
|
| 20 |
|
| 21 |
+
// Segmentation result data (embedded in job response)
|
| 22 |
export interface SegmentResponse {
|
| 23 |
caseId: string
|
| 24 |
diceScore: number | null
|
|
|
|
| 27 |
dwiUrl: string
|
| 28 |
predictionUrl: string
|
| 29 |
}
|
| 30 |
+
|
| 31 |
+
// Job Status Types
|
| 32 |
+
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed'
|
| 33 |
+
|
| 34 |
+
// Response from POST /api/segment (job creation)
|
| 35 |
+
export interface CreateJobResponse {
|
| 36 |
+
jobId: string
|
| 37 |
+
status: JobStatus
|
| 38 |
+
message: string
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Response from GET /api/jobs/{jobId} (status polling)
|
| 42 |
+
export interface JobStatusResponse {
|
| 43 |
+
jobId: string
|
| 44 |
+
status: JobStatus
|
| 45 |
+
progress: number
|
| 46 |
+
progressMessage: string
|
| 47 |
+
elapsedSeconds?: number
|
| 48 |
+
result?: SegmentResponse
|
| 49 |
+
error?: string
|
| 50 |
+
}
|