From 09436ef0a44d091317df5682c5a561f9686e8926 Mon Sep 17 00:00:00 2001 From: jonas Date: Thu, 3 Sep 2026 13:30:56 +0200 Subject: [PATCH 1/3] added json batch and did visual adjustments --- tests/test_web_api.py | 235 +++++++++++++ web/backend/main.py | 2 + web/backend/models.py | 18 + web/backend/routes/regenerate.py | 123 ++++++- web/frontend/package.json | 2 +- web/frontend/src/components/AppFooter.jsx | 4 +- .../src/components/AppFooter.test.jsx | 4 +- web/frontend/src/components/DashboardView.jsx | 4 + .../src/components/tabs/AnalyzeTab.jsx | 167 +++++---- web/frontend/src/constants.js | 6 +- web/frontend/src/hooks/useBatchManager.js | 56 ++- .../src/hooks/useBatchManager.test.js | 135 ++++++++ web/frontend/src/styles.css | 322 +++++++++++++++++- web/frontend/src/useDashboardLogic.js | 2 + 14 files changed, 997 insertions(+), 83 deletions(-) diff --git a/tests/test_web_api.py b/tests/test_web_api.py index b3e6b2bd..7648e381 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -2328,6 +2328,241 @@ def capturing_enqueue(*args, **kwargs): assert enqueued_bam_paths == [None, None] +class TestBatchRegenerateEndpoints: + """Batch regenerate-from-JSON endpoint: /api/regenerate/batch. + + Mirrors the single ``/api/regenerate/json`` route but accepts a list of uploaded + results-JSON IDs. Each JSON resolves its own project DB via the stored + ``project_fingerprint`` (with a per-request fallback ``database_id``), so a batch + may span databases — unlike VCF/FASTA batches which share one ``db_path``. + """ + + @pytest.fixture(autouse=True) + def _reset_sample_quota(self): + _SAMPLE_QUOTA_COUNTER.clear() + reset_memory_stores() + yield + _SAMPLE_QUOTA_COUNTER.clear() + reset_memory_stores() + + def _default_db(self, startup_config: StartupConfig) -> str: + return sorted(startup_config.project_databases_dir.glob('*.db'))[0].name + + def _produce_results_json_bytes( + self, + client: TestClient, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + sample_name: str, + ) -> bytes: + """Run one VCF profile job and return the bytes of its results-JSON artifact.""" + vcf_id = _upload_file(client, web_sample_vcf, 'vcf') + ref_id = _upload_file(client, web_sample_ref_fasta, 'fasta') + submit = client.post( + '/api/profile/vcf', + json={ + 'vcf_id': vcf_id, + 'reference_id': ref_id, + 'sample': sample_name, + }, + ) + assert submit.status_code == 200, submit.text + payload = _poll_job(client, submit.json()['job_id']) + assert payload['status'] == 'succeeded', payload + json_artifact_id = payload['result']['report_json_path'] + return client.get('/api/artifact', params={'artifact_id': json_artifact_id}).content + + def test_batch_regenerate_submit_success( + self, + client: TestClient, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + ) -> None: + json_a = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-a', + ) + json_b = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-b', + ) + json_id_a = _upload_bytes(client, json_a, 'json', 'sample-a.results.json') + json_id_b = _upload_bytes(client, json_b, 'json', 'sample-b.results.json') + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id_a, json_id_b], + 'sample_names': ['regen-a', 'regen-b'], + 'input_display_names': ['sample-a.results.json', 'sample-b.results.json'], + 'database_id': self._default_db(startup_config), + }, + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data['total'] == 2 + assert len(data['samples']) == 2 + for entry in data['samples']: + assert entry['job_id'] + assert entry['status'] == 'queued' + + def test_batch_regenerate_jobs_resolve_to_regenerated_artifacts( + self, + client: TestClient, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + ) -> None: + json_bytes = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-resolve', + ) + json_id = _upload_bytes(client, json_bytes, 'json', 'resolve.results.json') + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id], + 'sample_names': ['regen-resolve'], + 'input_display_names': ['resolve.results.json'], + 'database_id': self._default_db(startup_config), + }, + ) + assert response.status_code == 200, response.text + submitted = response.json()['samples'] + + for sample in submitted: + payload = _poll_job(client, sample['job_id']) + assert payload['status'] == 'succeeded', payload + result = payload['result'] + assert result['mode'] == 'regenerate-json' + assert result['report_html_path'] + assert result['report_json_path'] + assert result['report_pdf_path'] + + def test_batch_regenerate_mismatched_lengths_returns_422( + self, + client: TestClient, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + ) -> None: + json_bytes = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-mismatch', + ) + json_id = _upload_bytes(client, json_bytes, 'json', 'mismatch.results.json') + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id, json_id], + 'sample_names': ['only-one'], + 'database_id': self._default_db(startup_config), + }, + ) + + assert response.status_code == 422 + assert 'json_ids and sample_names must have the same length.' in response.json()['detail'] + + def test_batch_regenerate_exceeds_max_size_returns_422( + self, + client: TestClient, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + ) -> None: + json_bytes = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-oversize', + ) + json_id = _upload_bytes(client, json_bytes, 'json', 'oversize.results.json') + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id] * 26, + 'sample_names': [f'regen-{i}' for i in range(26)], + 'database_id': self._default_db(startup_config), + }, + ) + + assert response.status_code == 422 + detail = str(response.json()) + assert 'batch' in detail.lower() or '25' in detail + + def test_batch_regenerate_missing_json_file_returns_404_without_partial_enqueue( + self, + client: TestClient, + sync_queue: Queue, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + json_bytes = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-partial', + ) + json_id = _upload_bytes(client, json_bytes, 'json', 'partial.results.json') + + enqueue_calls = 0 + original_enqueue = sync_queue.enqueue + + def counting_enqueue(*args, **kwargs): + nonlocal enqueue_calls + enqueue_calls += 1 + return original_enqueue(*args, **kwargs) + + monkeypatch.setattr(sync_queue, 'enqueue', counting_enqueue) + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id, 'nonexistent-json-id'], + 'sample_names': ['regen-a', 'regen-b'], + 'database_id': self._default_db(startup_config), + }, + ) + + assert response.status_code == 404 + assert 'JSON file not found for sample' in response.json()['detail'] + assert enqueue_calls == 0 + + def test_batch_regenerate_rate_limit_returns_429( + self, + client: TestClient, + startup_config: StartupConfig, + web_sample_vcf: Path, + web_sample_ref_fasta: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Exceeding the per-minute sample quota returns 429 with no jobs enqueued.""" + json_bytes = self._produce_results_json_bytes( + client, web_sample_vcf, web_sample_ref_fasta, 'batch-regen-ratelimit', + ) + json_id = _upload_bytes(client, json_bytes, 'json', 'ratelimit.results.json') + + # Force the in-memory quota path (no Redis) and a fixed identity so we can + # pre-saturate the counter for the exact key the route will use. + monkeypatch.delenv('REDIS_URL', raising=False) + import web.backend.main as main_module + + fixed_identity = 'ip:testclient' + monkeypatch.setattr(main_module, '_rate_limit_key', lambda request: fixed_identity) + max_per_minute = WEB_BACKEND_CONFIG.defaults.max_batch_size + window_minute = main_module._current_window_minute() + _SAMPLE_QUOTA_COUNTER.clear() + _SAMPLE_QUOTA_COUNTER[(fixed_identity, window_minute)] = max_per_minute + + response = client.post( + '/api/regenerate/batch', + json={ + 'json_ids': [json_id], + 'sample_names': ['regen-ratelimit'], + 'database_id': self._default_db(startup_config), + }, + ) + + assert response.status_code == 429 + + class TestLegalRoute: """Legal notice / imprint route across the four configuration states. diff --git a/web/backend/main.py b/web/backend/main.py index 795a1a4b..3a2a580f 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -215,6 +215,8 @@ def create_app(startup_config: StartupConfig | None = None) -> FastAPI: app.include_router( build_regenerate_router( config=config, + sample_limit_per_minute=sample_limit_per_minute, + consume_sample_quota=_consume_sample_quota, user_facing_error_message=_user_facing_error_message, is_path_within_allowed_roots=is_path_within_allowed_roots, resolve_regenerate_project_db_path=resolve_regenerate_project_db_path, diff --git a/web/backend/models.py b/web/backend/models.py index 569294e2..79c900af 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -135,6 +135,24 @@ class BatchProfileFastaPayload(BaseModel): threads: int = Field(default=1, ge=1, le=WEB_BACKEND_CONFIG.defaults.profile_max_threads) +class BatchRegenerateJsonPayload(BaseModel): + """Payload for submitting a batch regenerate-from-JSON job. + + Each uploaded results-JSON ID is resolved to its own project database via the + stored ``project_fingerprint`` (with ``database_id`` as a per-request fallback), + so a single batch may span databases — unlike VCF/FASTA batches which share one + ``db_path``. Inputs are referenced by opaque upload IDs. + """ + + json_ids: list[str] = Field(max_length=WEB_BACKEND_CONFIG.defaults.path_list_max_length) + sample_names: list[str] = Field(max_length=WEB_BACKEND_CONFIG.defaults.path_list_max_length) + input_display_names: list[str] | None = Field( + default=None, + max_length=WEB_BACKEND_CONFIG.defaults.path_list_max_length, + ) + database_id: str | None = Field(default=None, max_length=WEB_BACKEND_CONFIG.defaults.opaque_id_max_length) + + class UploadResponse(BaseModel): """Response returned after a file is successfully uploaded. diff --git a/web/backend/routes/regenerate.py b/web/backend/routes/regenerate.py index 5492f61f..c248932e 100644 --- a/web/backend/routes/regenerate.py +++ b/web/backend/routes/regenerate.py @@ -5,6 +5,7 @@ import logging from collections.abc import Callable from pathlib import Path +from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Request from rq import Queue @@ -12,8 +13,17 @@ from respro.db.results import load_run_from_json from web.backend.jobs import run_regenerate_json -from web.backend.models import JobSubmitResponse, RegenerateJsonPayload -from web.backend.queue import build_enqueue_job_options, get_queue +from web.backend.models import ( + BatchRegenerateJsonPayload, + BatchSampleEntry, + BatchSubmitResponse, + JobSubmitResponse, + RegenerateJsonPayload, +) +from web.backend.queue import build_enqueue_job_options, get_batch_queue, get_queue +from web.backend.routes.profile import ( + _resolve_batch_input_display_names, +) from web.backend.services.session import ( Session, record_job, @@ -26,6 +36,8 @@ def build_regenerate_router( *, config, + sample_limit_per_minute: int, + consume_sample_quota: Callable[[Request, int, int], None], user_facing_error_message: Callable[[str | None], str], is_path_within_allowed_roots: Callable[[Path, tuple[Path, ...]], bool], resolve_regenerate_project_db_path, @@ -33,7 +45,7 @@ def build_regenerate_router( api_rate_limit: str, get_session: Callable[..., Session], ) -> APIRouter: - """Build regenerate-json route.""" + """Build regenerate-json routes (single + batch).""" router = APIRouter() @router.post('/api/regenerate/json', response_model=JobSubmitResponse) @@ -88,4 +100,109 @@ def regenerate_json_route( ) return JobSubmitResponse(job_id=job.id) + @router.post('/api/regenerate/batch', response_model=BatchSubmitResponse) + @limiter.limit(api_rate_limit) + def regenerate_batch_json_route( + request: Request, + payload: BatchRegenerateJsonPayload, + queue: Queue = Depends(get_batch_queue), + ) -> BatchSubmitResponse: + """Enqueue one regenerate-from-JSON job per uploaded results JSON. + + Unlike the VCF/FASTA batch routes, each JSON resolves its own project + database via the stored ``project_fingerprint`` (with ``database_id`` as a + shared fallback), so a single batch may regenerate reports against more + than one database. The per-minute sample quota applies to the batch size. + """ + session = get_session(request) + if len(payload.json_ids) != len(payload.sample_names): + raise HTTPException( + status_code=422, + detail='json_ids and sample_names must have the same length.', + ) + # Resolve and validate display names (length check) even though the regenerate + # job derives its output name from the uploaded JSON filename — this keeps the + # input_display_names contract consistent with the VCF/FASTA batch routes. + _resolve_batch_input_display_names( + input_paths=payload.json_ids, + input_display_names=payload.input_display_names, + path_label='json_ids', + ) + max_batch = sample_limit_per_minute + if len(payload.json_ids) > max_batch: + raise HTTPException( + status_code=422, + detail=f'Batch size {len(payload.json_ids)} exceeds the maximum of {max_batch} samples per batch.', + ) + consume_sample_quota( + request, + sample_count=len(payload.json_ids), + sample_limit_per_minute=sample_limit_per_minute, + ) + + # Resolve every uploaded JSON path and its project DB up front so a missing + # file or unresolvable fingerprint fails the whole batch with a clear error + # before any job is enqueued (no partial enqueue, mirroring VCF/FASTA batches). + resolved_entries: list[tuple[Path, Path]] = [] + for index, json_id in enumerate(payload.json_ids): + sample_name = payload.sample_names[index] + try: + json_path = resolve_owned_path( + prefix='upload', + record_id=json_id, + session_hash=session.session_hash, + allowed_roots=config.allowed_roots, + ) + except LookupError as exc: + raise HTTPException( + status_code=404, + detail=f'JSON file not found for sample {sample_name!r}.', + ) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not json_path.is_file(): + raise HTTPException( + status_code=404, + detail=f'JSON file not found for sample {sample_name!r}.', + ) + try: + run_dict, _, _, _, _ = load_run_from_json(json_path) + project_db_uuid_index = request.app.state.project_db_uuid_index + project_db = resolve_regenerate_project_db_path( + config.project_databases_dir, + project_db_uuid_index, + project_fingerprint=str(run_dict.get('project_fingerprint', '') or ''), + fallback_database_id=payload.database_id, + ) + except ValueError as exc: + raise HTTPException( + status_code=400, + detail=user_facing_error_message(str(exc)), + ) from exc + resolved_entries.append((json_path, project_db)) + + enqueue_options = build_enqueue_job_options() + samples: list[BatchSampleEntry] = [] + for index, (json_path, project_db) in enumerate(resolved_entries): + job_id = str(uuid4()) + queue.enqueue( + run_regenerate_json, + project_db=str(project_db), + output_dir=str(config.results_dir), + json_path=str(json_path), + job_id=job_id, + **enqueue_options, + ) + record_job( + session_hash=session.session_hash, + upload_ids=[payload.json_ids[index]], + job_id=job_id, + ) + samples.append(BatchSampleEntry(job_id=job_id, sample_name=payload.sample_names[index])) + logger.info( + 'Batch regenerate-json jobs enqueued: count=%d', + len(samples), + ) + return BatchSubmitResponse(samples=samples, total=len(samples)) + return router diff --git a/web/frontend/package.json b/web/frontend/package.json index 756e3d51..402a2c98 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -1,6 +1,6 @@ { "name": "respro-web-frontend", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "engines": { diff --git a/web/frontend/src/components/AppFooter.jsx b/web/frontend/src/components/AppFooter.jsx index 8d767b65..a8f106ab 100644 --- a/web/frontend/src/components/AppFooter.jsx +++ b/web/frontend/src/components/AppFooter.jsx @@ -24,13 +24,13 @@ export function AppFooter({ legalLink, contactEmail, cliVersion, webVersion }) { {(cliVersion || webVersion) && (
{cliVersion && ( - ResistanceProfiler Core v{cliVersion} + ResPro Core v{cliVersion} )} {cliVersion && webVersion && ( )} {webVersion && ( - ResistanceProfiler WebApp v{webVersion} + ResPro WebApp v{webVersion} )}
)} diff --git a/web/frontend/src/components/AppFooter.test.jsx b/web/frontend/src/components/AppFooter.test.jsx index 5b910dc1..25b06801 100644 --- a/web/frontend/src/components/AppFooter.test.jsx +++ b/web/frontend/src/components/AppFooter.test.jsx @@ -12,8 +12,8 @@ describe('AppFooter', () => { render(); expect(screen.queryByText('Legal notice')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /contact/i })).not.toBeInTheDocument(); - expect(screen.queryByText(/ResistanceProfiler Core/)).not.toBeInTheDocument(); - expect(screen.queryByText(/ResistanceProfiler WebApp/)).not.toBeInTheDocument(); + expect(screen.queryByText(/ResPro Core/)).not.toBeInTheDocument(); + expect(screen.queryByText(/ResPro WebApp/)).not.toBeInTheDocument(); }); it('renders a mailto contact link only when contactEmail is set', () => { diff --git a/web/frontend/src/components/DashboardView.jsx b/web/frontend/src/components/DashboardView.jsx index c98c16e8..62dc17ae 100644 --- a/web/frontend/src/components/DashboardView.jsx +++ b/web/frontend/src/components/DashboardView.jsx @@ -98,6 +98,7 @@ export function DashboardView({ setBatchMode, batchVcfFiles, batchFastaFiles, + batchJsonFiles, batchReferenceFasta, batchSamples, batchSubmitting, @@ -113,6 +114,7 @@ export function DashboardView({ setBatchVcfCutoffs, addBatchVcfFiles, addBatchFastaFiles, + addBatchJsonFiles, addBatchBamFiles, attachBatchBam, removeBatchFile, @@ -283,6 +285,7 @@ export function DashboardView({ setBatchMode={setBatchMode} batchVcfFiles={batchVcfFiles} batchFastaFiles={batchFastaFiles} + batchJsonFiles={batchJsonFiles} batchReferenceFasta={batchReferenceFasta} batchSamples={batchSamples} batchSubmitting={batchSubmitting} @@ -297,6 +300,7 @@ export function DashboardView({ setBatchVcfCutoffs={setBatchVcfCutoffs} addBatchVcfFiles={addBatchVcfFiles} addBatchFastaFiles={addBatchFastaFiles} + addBatchJsonFiles={addBatchJsonFiles} addBatchBamFiles={addBatchBamFiles} attachBatchBam={attachBatchBam} removeBatchFile={removeBatchFile} diff --git a/web/frontend/src/components/tabs/AnalyzeTab.jsx b/web/frontend/src/components/tabs/AnalyzeTab.jsx index d4cecce3..06294c99 100644 --- a/web/frontend/src/components/tabs/AnalyzeTab.jsx +++ b/web/frontend/src/components/tabs/AnalyzeTab.jsx @@ -3,14 +3,10 @@ import { useEffect, useRef, useState } from 'react'; import analyzeIconSrc from '../../assets/icon-analyze.svg'; import batchIconSrc from '../../assets/batch.svg'; import exampleIconSrc from '../../assets/example.svg'; +import fileIconSrc from '../../assets/file.svg'; import infoIconSrc from '../../assets/info.svg'; import { Spinner } from '../Spinner'; -const ANALYZE_SUBMODES = [ - { id: 'single', label: 'One Sample', iconSrc: analyzeIconSrc }, - { id: 'batch', label: 'Multiple Samples', iconSrc: batchIconSrc }, -]; - export function AnalyzeTab({ selectedDatabase, vcfInput, @@ -46,6 +42,7 @@ export function AnalyzeTab({ setBatchMode, batchVcfFiles, batchFastaFiles, + batchJsonFiles, batchReferenceFasta, batchSamples, batchSubmitting, @@ -60,6 +57,7 @@ export function AnalyzeTab({ setBatchVcfCutoffs, addBatchVcfFiles, addBatchFastaFiles, + addBatchJsonFiles, addBatchBamFiles, attachBatchBam, removeBatchFile, @@ -202,13 +200,6 @@ export function AnalyzeTab({

{analyzeSubMode === 'batch' ? 'Analyze multiple samples' : 'Analyze'}

-

- {analyzeSubMode === 'batch' - ? 'Submit multiple sequence file at once (max 25 per batch and minute).' - : 'Profile vcf files or consensus fasta sequence or regenerate a previous report. Bam files are optional and can be used for coverage analysis.'} -

-
-
{selectedDatabase?.has_example ? (
+ {analyzeSubMode === 'single' || !batchSubmitted ? ( +
+
+
+ Upload progress + {uploadProgress.percent}% +
+ +
+ ) : null} +
+
+
+ + - ))} + }} + disabled={isAnalyzeScopeLocked} + > +
+
@@ -258,7 +279,7 @@ export function AnalyzeTab({
-
+
{PROFILE_MODES.map((mode) => (
-
-
- Upload progress - {uploadProgress.percent}% -
-
{activeProfileMode === 'vcf' ? ( @@ -524,6 +536,15 @@ export function AnalyzeTab({ Download TSV
+ + {!inlineReportPath ? ( +
+
+ +
+

Your analysis report will open here

+
+ ) : null}
) : null} @@ -535,14 +556,14 @@ export function AnalyzeTab({ {/* Mode sub-selector */}
-
+
+ -
-
-
-
- Upload progress - {uploadProgress.percent}% -
-
@@ -649,7 +669,7 @@ export function AnalyzeTab({ />
- ) : ( + ) : batchMode === 'fasta' ? (
+ ) : ( +
+ +
)} {/* Uploaded file list */} - {(batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles).length > 0 ? ( + {(() => { + const batchFiles = batchMode === 'vcf' ? batchVcfFiles : batchMode === 'fasta' ? batchFastaFiles : batchJsonFiles; + const fileColumnLabel = batchMode === 'vcf' ? 'VCF file' : batchMode === 'fasta' ? 'FASTA file' : 'JSON file'; + return batchFiles.length > 0 ? (

- {(batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles).length} / {batchMaxSamples} files - {(batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles).length >= batchMaxSamples ? ( + {batchFiles.length} / {batchMaxSamples} files + {batchFiles.length >= batchMaxSamples ? ( Limit reached ) : null}

@@ -683,7 +725,7 @@ export function AnalyzeTab({ - + {batchMode === 'vcf' ? : null} {batchMode === 'vcf' ? : null} @@ -692,7 +734,7 @@ export function AnalyzeTab({ - {(batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles).map((file, index) => ( + {batchFiles.map((file, index) => ( @@ -741,7 +783,8 @@ export function AnalyzeTab({
{batchMode === 'vcf' ? 'VCF file' : 'FASTA file'}{fileColumnLabel} SizeBAM fileBAM size
{file.name} {Math.round(file.size / 1024)} KB
- ) : null} + ) : null; + })()} {/* Shared reference FASTA selection status (VCF mode) */} {batchMode === 'vcf' && batchReferenceFasta ? ( @@ -757,7 +800,7 @@ export function AnalyzeTab({ disabled={ batchSubmitting || batchRateLimitCooldown > 0 - || (batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles).length === 0 + || (batchMode === 'vcf' ? batchVcfFiles : batchMode === 'fasta' ? batchFastaFiles : batchJsonFiles).length === 0 || (batchMode === 'vcf' && !batchReferenceFasta) } > diff --git a/web/frontend/src/constants.js b/web/frontend/src/constants.js index 4213638e..ba52e44d 100644 --- a/web/frontend/src/constants.js +++ b/web/frontend/src/constants.js @@ -1,5 +1,5 @@ export const PROFILE_MODES = [ - { id: 'vcf', label: 'VCF mode' }, - { id: 'fasta', label: 'FASTA mode' }, - { id: 'regenerate', label: 'Regenerate mode' }, + { id: 'vcf', label: 'VCF' }, + { id: 'fasta', label: 'FASTA' }, + { id: 'regenerate', label: 'JSON' }, ]; diff --git a/web/frontend/src/hooks/useBatchManager.js b/web/frontend/src/hooks/useBatchManager.js index e77da7a3..47290b05 100644 --- a/web/frontend/src/hooks/useBatchManager.js +++ b/web/frontend/src/hooks/useBatchManager.js @@ -14,6 +14,7 @@ export function useBatchManager({ const [batchMode, setBatchMode] = useState('vcf'); const [batchVcfFiles, setBatchVcfFiles] = useState([]); const [batchFastaFiles, setBatchFastaFiles] = useState([]); + const [batchJsonFiles, setBatchJsonFiles] = useState([]); const [batchReferenceFasta, setBatchReferenceFastaState] = useState(null); const [batchSamples, setBatchSamples] = useState([]); const [batchSubmitting, setBatchSubmitting] = useState(false); @@ -188,11 +189,36 @@ export function useBatchManager({ } }; + const addBatchJsonFiles = async (files) => { + const toUpload = Array.from(files).slice(0, batchMaxSamples - batchJsonFiles.length); + for (const file of toUpload) { + try { + setUploadProgress({ + percent: 0, + fileName: `BATCH JSON - ${file.name}`, + }); + const response = await apiUpload('/api/upload/json', file, (percent) => { + setUploadProgress((prev) => ({ + ...prev, + percent, + })); + }); + setUploadProgress((prev) => ({ ...prev, percent: 100 })); + setBatchJsonFiles((prev) => [...prev, { uploadId: response.upload_id, name: file.name, size: file.size }]); + addUploadedPath(response.upload_id); + } catch (error) { + setBatchError(formatUserError(error.message)); + } + } + }; + const removeBatchFile = (index) => { if (batchMode === 'vcf') { setBatchVcfFiles((prev) => prev.filter((_, i) => i !== index)); - } else { + } else if (batchMode === 'fasta') { setBatchFastaFiles((prev) => prev.filter((_, i) => i !== index)); + } else { + setBatchJsonFiles((prev) => prev.filter((_, i) => i !== index)); } }; @@ -278,7 +304,7 @@ export function useBatchManager({ const submitBatch = async () => { setBatchError(null); setBatchSubmitting(true); - const files = batchMode === 'vcf' ? batchVcfFiles : batchFastaFiles; + const files = batchMode === 'vcf' ? batchVcfFiles : batchMode === 'fasta' ? batchFastaFiles : batchJsonFiles; if (files.length === 0) { setBatchError('No files uploaded.'); setBatchSubmitting(false); @@ -316,7 +342,7 @@ export function useBatchManager({ throw new Error(formatUserError(payload.detail || `Request failed: ${response.status}`)); } responseData = await response.json(); - } else { + } else if (batchMode === 'fasta') { const sampleNames = batchFastaFiles.map((file) => formatPathStem(file.name)); const body = { fasta_ids: batchFastaFiles.map((f) => f.uploadId), @@ -335,6 +361,27 @@ export function useBatchManager({ throw new Error(formatUserError(payload.detail || `Request failed: ${response.status}`)); } responseData = await response.json(); + } else { + // JSON regenerate batch: each results-JSON resolves its own project DB via the + // stored fingerprint on the backend, so no shared db_path is sent. + const sampleNames = batchJsonFiles.map((file) => formatPathStem(file.name)); + const body = { + json_ids: batchJsonFiles.map((f) => f.uploadId), + sample_names: sampleNames, + input_display_names: batchJsonFiles.map((f) => f.name), + database_id: selectedDatabaseId, + }; + const response = await apiPostRaw('/api/regenerate/batch', body); + if (response.status === 429) { + setBatchRateLimitCooldown(60); + setBatchError(`Rate limit reached. At most ${sampleLimitPerMinute} samples can be analyzed per minute.`); + return; + } + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(formatUserError(payload.detail || `Request failed: ${response.status}`)); + } + responseData = await response.json(); } const initialSamples = responseData.samples.map((s) => ({ ...s, @@ -357,6 +404,7 @@ export function useBatchManager({ const resetBatch = () => { setBatchVcfFiles([]); setBatchFastaFiles([]); + setBatchJsonFiles([]); setBatchReferenceFastaState(null); setBatchSamples([]); setBatchSubmitting(false); @@ -389,6 +437,7 @@ export function useBatchManager({ setBatchMode, batchVcfFiles, batchFastaFiles, + batchJsonFiles, batchReferenceFasta, batchSamples, batchSubmitting, @@ -405,6 +454,7 @@ export function useBatchManager({ setBatchVcfCutoffs, addBatchVcfFiles, addBatchFastaFiles, + addBatchJsonFiles, addBatchBamFiles, attachBatchBam, removeBatchBam, diff --git a/web/frontend/src/hooks/useBatchManager.test.js b/web/frontend/src/hooks/useBatchManager.test.js index af82c613..dc508f9b 100644 --- a/web/frontend/src/hooks/useBatchManager.test.js +++ b/web/frontend/src/hooks/useBatchManager.test.js @@ -330,3 +330,138 @@ describe('useBatchManager — batch BAM auto-pairing and per-row override', () = }); }); }); + +describe('useBatchManager — JSON regenerate batch', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch.mockReset(); + mockXHRInstances.length = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Upload a results-JSON file into the hook via addBatchJsonFiles. + async function uploadJson(result, fileName, uploadId) { + const file = new File(['{"run":{}}'], fileName, { type: 'application/json' }); + let promise; + await act(async () => { + promise = result.current.addBatchJsonFiles([file]); + mockXHRInstance.triggerSuccess({ upload_id: uploadId, file_type: 'json', size_bytes: 12 }); + await promise; + }); + } + + it('addBatchJsonFiles uploads via /api/upload/json and records the file', async () => { + const stubs = makeStubs(); + const { result } = renderHook(() => useBatchManager(stubs)); + + await uploadJson(result, 'sample1.results.json', 'up-json-1'); + + expect(result.current.batchJsonFiles).toHaveLength(1); + expect(result.current.batchJsonFiles[0].uploadId).toBe('up-json-1'); + expect(result.current.batchJsonFiles[0].name).toBe('sample1.results.json'); + expect(stubs.addUploadedPath).toHaveBeenCalledWith('up-json-1'); + }); + + it('removeBatchFile drops a JSON row in json mode', async () => { + const stubs = makeStubs(); + const { result } = renderHook(() => useBatchManager(stubs)); + + await uploadJson(result, 'sample1.results.json', 'up-json-1'); + await uploadJson(result, 'sample2.results.json', 'up-json-2'); + act(() => { + result.current.setBatchMode('json'); + }); + + act(() => { + result.current.removeBatchFile(0); + }); + + expect(result.current.batchJsonFiles).toHaveLength(1); + expect(result.current.batchJsonFiles[0].name).toBe('sample2.results.json'); + }); + + it('submitBatch JSON branch POSTs /api/regenerate/batch with json_ids and database_id', async () => { + const stubs = makeStubs(); + const { result } = renderHook(() => useBatchManager(stubs)); + + await uploadJson(result, 'sample1.results.json', 'up-json-1'); + await uploadJson(result, 'sample2.results.json', 'up-json-2'); + act(() => { + result.current.setBatchMode('json'); + }); + + let submittedBody; + global.fetch.mockImplementation(async (url, options) => { + if (options && options.body) { + submittedBody = JSON.parse(options.body); + } + return { + ok: true, + status: 200, + json: async () => ({ + samples: [ + { job_id: 'j1', sample_name: 'sample1', status: 'succeeded' }, + { job_id: 'j2', sample_name: 'sample2', status: 'succeeded' }, + ], + total: 2, + }), + }; + }); + + await act(async () => { + await result.current.submitBatch(); + }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + expect(submittedBody.json_ids).toEqual(['up-json-1', 'up-json-2']); + expect(submittedBody.sample_names).toEqual(['sample1.results', 'sample2.results']); + expect(submittedBody.input_display_names).toEqual(['sample1.results.json', 'sample2.results.json']); + expect(submittedBody.database_id).toBe('db1'); + // JSON regenerate has no shared reference or cutoffs. + expect(submittedBody.reference_id).toBeUndefined(); + expect(submittedBody.db_path).toBeUndefined(); + expect(submittedBody.min_af).toBeUndefined(); + }); + + it('submitBatch JSON branch surfaces a 429 rate limit via batchError', async () => { + const stubs = makeStubs(); + const { result } = renderHook(() => useBatchManager(stubs)); + + await uploadJson(result, 'sample1.results.json', 'up-json-1'); + act(() => { + result.current.setBatchMode('json'); + }); + + global.fetch.mockImplementation(async () => ({ + ok: false, + status: 429, + json: async () => ({ detail: 'rate limited' }), + })); + + await act(async () => { + await result.current.submitBatch(); + }); + + await waitFor(() => { + expect(result.current.batchError).toMatch(/Rate limit/); + expect(result.current.batchRateLimitCooldown).toBe(60); + }); + }); + + it('resetBatch clears JSON files', async () => { + const stubs = makeStubs(); + const { result } = renderHook(() => useBatchManager(stubs)); + + await uploadJson(result, 'sample1.results.json', 'up-json-1'); + act(() => { + result.current.resetBatch(); + }); + + expect(result.current.batchJsonFiles).toHaveLength(0); + }); +}); diff --git a/web/frontend/src/styles.css b/web/frontend/src/styles.css index dc15ae49..148110a8 100644 --- a/web/frontend/src/styles.css +++ b/web/frontend/src/styles.css @@ -31,6 +31,8 @@ --font-size-caption: var(--font-size-xs); --font-size-table: var(--font-size-sm); --font-size-button: var(--font-size-sm); + --analysis-slider-width: 21.5rem; + --analysis-progress-width: 28rem; --radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; @@ -1558,25 +1560,174 @@ th { .analyze-shell-header h2 { grid-column: 1; + grid-row: 1; + } + + .analyze-shell-header .example-btn { + grid-column: 2; + grid-row: 1; + align-self: center; + justify-self: end; } .analyze-shell-header p { - grid-column: 1 / -1; + grid-column: 1; + grid-row: 2; + } + + .analyze-submode-progress { + grid-column: 2; + grid-row: 2; } .analyze-submode-row { + grid-column: 1 / -1; + grid-row: 3; + } +} + +@media (max-width: 820px) { + .analyze-shell-header > div:first-child { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: 0.75rem; + row-gap: 0.35rem; + } + + .analyze-shell-header h2 { + grid-column: 1; + grid-row: 1; + } + + .analyze-shell-header .example-btn { grid-column: 2; grid-row: 1; + align-self: center; + height: 2rem; + } + + .analyze-shell-header p { + grid-column: 1 / -1; + grid-row: 2; } } .analyze-submode-row { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; +} + +.analyze-submode-progress { + width: var(--analysis-progress-width); +} + +.analyze-submode-progress .upload-progress { + width: 100%; + min-width: 0; + max-width: none; + margin: 0; +} + +.analyze-submode-controls { + order: 1; display: inline-flex; + align-items: stretch; gap: 0.35rem; +} + +/* One/multiple-sample toggle — an animated sliding switch (track + knob). */ +.analyze-submode-switch { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center; - justify-self: end; - flex-shrink: 0; - flex-wrap: wrap; + width: var(--analysis-slider-width); + min-width: 0; + border: 1px solid var(--accent); + border-radius: 999px; + background: var(--accent); + height: 2rem; + padding: 0.14rem; + gap: 0; + isolation: isolate; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 16%); +} + +.analyze-submode-switch-option { + position: relative; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + background: transparent; + color: var(--on-accent); + height: 100%; + padding: 0 0.65rem; + font-size: var(--font-size-button); + font-weight: 700; + cursor: pointer; + white-space: nowrap; + border-radius: 999px; + transition: color 0.22s ease, opacity 0.22s ease; +} + +.analyze-submode-switch-option.active { + color: var(--accent); +} + +.analyze-submode-switch-option:hover:not(:disabled) { + background: transparent; +} + +.analyze-submode-switch-option.active:hover:not(:disabled) { + color: var(--accent); +} + +.analyze-submode-switch-option:not(.active):hover:not(:disabled) { + color: var(--on-accent); + opacity: 0.82; +} + +.analyze-submode-switch-option:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.analyze-submode-switch-option .analyze-submode-icon { + opacity: 0.82; + transition: opacity 0.22s ease; +} + +.analyze-submode-switch-option.active .analyze-submode-icon { + opacity: 1; +} + +.analyze-submode-switch-option:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +/* The sliding knob sits behind the options and translates to the active side. */ +.analyze-submode-switch-knob { + position: absolute; + z-index: 1; + top: 0.14rem; + left: 0.14rem; + bottom: 0.14rem; + width: calc(50% - 0.14rem); + border-radius: 999px; + background: var(--surface-strong); + box-shadow: 0 2px 5px rgb(0 0 0 / 18%), inset 0 1px rgb(255 255 255 / 72%); + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +/* When the switch is in the "batch" state, slide the knob to the right half. */ +.analyze-submode-switch[data-state='batch'] .analyze-submode-switch-knob { + transform: translateX(100%); } .analyze-regenerate-progress-row { @@ -1619,6 +1770,77 @@ th { cursor: not-allowed; } +.example-btn { + display: inline-flex; + width: fit-content; + height: 2rem; + padding: 0 0.72rem; +} + +/* Profile input modes use the same filled-slider language as the workflow switch. */ +.profile-settings-row .database-phenotype-switch { + --profile-mode-accent: #7e7f80; + position: relative; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: var(--analysis-slider-width); + min-width: 0; + height: 2rem; + gap: 0; + padding: 0.14rem; + border: 1px solid var(--profile-mode-accent); + border-radius: 999px; + background: var(--profile-mode-accent); + isolation: isolate; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 16%); +} + +.profile-settings-row .database-phenotype-switch::before { + position: absolute; + z-index: 1; + top: 0.14rem; + bottom: 0.14rem; + left: 0.14rem; + width: calc((100% - 0.28rem) / 3); + border-radius: 999px; + background: var(--surface-strong); + box-shadow: 0 2px 5px rgb(0 0 0 / 18%), inset 0 1px rgb(255 255 255 / 72%); + content: ''; + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +.profile-settings-row .database-phenotype-switch[data-active-mode='fasta']::before { + transform: translateX(100%); +} + +.profile-settings-row .database-phenotype-switch[data-active-mode='regenerate']::before, +.profile-settings-row .database-phenotype-switch[data-active-mode='json']::before { + transform: translateX(200%); +} + +.profile-settings-row .database-phenotype-switch button { + position: relative; + z-index: 2; + height: 100%; + border: none; + border-radius: 999px; + background: transparent; + color: var(--on-accent); + padding: 0 0.55rem; +} + +.profile-settings-row .database-phenotype-switch button.active, +.profile-settings-row .database-phenotype-switch button.active:hover:not(:disabled) { + border-color: transparent; + background: transparent; + color: var(--profile-mode-accent); +} + +.profile-settings-row .database-phenotype-switch button:hover:not(:disabled) { + background: transparent; + color: var(--on-accent); +} + .analyze-submode-icon { width: 14px; height: 14px; @@ -1649,17 +1871,43 @@ th { .analyze-submode-row { width: 100%; justify-self: stretch; + flex-wrap: wrap; + } + + .analyze-submode-progress { + width: 100%; + margin-left: 0; + } + + .analyze-submode-controls { + width: 100%; + flex-wrap: wrap; } /* Let submode pills shrink and wrap their label instead of overflowing past the card edge when "Multiple Sample(s)" doesn't fit on one line. */ - .analyze-submode-btn { + .analyze-submode-btn, + .analyze-submode-switch-option { flex: 1 1 auto; min-width: 0; white-space: normal; text-align: center; justify-content: center; } + + .analyze-submode-switch { + width: 100%; + } + + .profile-settings-row .database-phenotype-switch { + min-width: 0; + width: 100%; + } + + .analyze-submode-switch-option { + flex: 1 1 auto; + justify-content: center; + } } .analyze-status-inline { @@ -2187,6 +2435,42 @@ th { box-shadow: var(--shadow-sm); } +/* Placeholder shown before a report is generated */ +.report-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1.5rem; + margin-block: auto; + min-height: 14rem; +} + +.report-placeholder-icon-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: 96px; + border-radius: 50%; + background: var(--accent-tint); + border: 2px solid var(--accent-soft); +} + +.report-placeholder-icon { + width: 44px; + height: 44px; + opacity: 0.75; +} + +.report-placeholder-text { + margin: 0; + color: var(--muted); + font-size: var(--font-size-md); + font-weight: 600; +} + .workspace-output-header { display: flex; flex-wrap: wrap; @@ -2536,7 +2820,17 @@ th.sortable-col { flex-wrap: wrap; } - .analyze-submode-btn { + .analyze-submode-progress, + .analyze-submode-controls { + width: 100%; + } + + .analyze-submode-progress { + margin-left: 0; + } + + .analyze-submode-btn, + .analyze-submode-switch-option { flex: 1 1 8rem; min-width: 0; max-width: 100%; @@ -2545,6 +2839,20 @@ th.sortable-col { justify-content: center; } + .analyze-submode-switch { + width: 100%; + min-width: 0; + } + + .profile-settings-row { + width: 100%; + } + + .profile-settings-row .database-phenotype-switch { + width: 100%; + min-width: 0; + } + .profile-upload-row, .profile-upload-row-fasta, .profile-upload-row-vcf, @@ -2785,7 +3093,7 @@ th.sortable-col { /* Ensure tappable controls meet the ≥44px target size on touch devices. */ .sidebar-rail-link, - .analyze-submode-btn, + .analyze-submode-btn:not(.example-btn), .mutation-download-button, .mutation-reset-button, .mobile-menu-btn { diff --git a/web/frontend/src/useDashboardLogic.js b/web/frontend/src/useDashboardLogic.js index 92cfacf4..f9eef864 100644 --- a/web/frontend/src/useDashboardLogic.js +++ b/web/frontend/src/useDashboardLogic.js @@ -246,6 +246,7 @@ export function useDashboardLogic() { setBatchMode: batch.setBatchMode, batchVcfFiles: batch.batchVcfFiles, batchFastaFiles: batch.batchFastaFiles, + batchJsonFiles: batch.batchJsonFiles, batchReferenceFasta: batch.batchReferenceFasta, batchSamples: batch.batchSamples, batchSubmitting: batch.batchSubmitting, @@ -260,6 +261,7 @@ export function useDashboardLogic() { setBatchVcfCutoffs: batch.setBatchVcfCutoffs, addBatchVcfFiles: batch.addBatchVcfFiles, addBatchFastaFiles: batch.addBatchFastaFiles, + addBatchJsonFiles: batch.addBatchJsonFiles, addBatchBamFiles: batch.addBatchBamFiles, attachBatchBam: batch.attachBatchBam, removeBatchFile: batch.removeBatchFile, From 655c985c24f46857669dd0035bd87080eb3481d4 Mon Sep 17 00:00:00 2001 From: jonas Date: Thu, 3 Sep 2026 15:18:34 +0200 Subject: [PATCH 2/3] visual mobile tweaks --- web/frontend/src/assets/file.svg | 24 +++ web/frontend/src/assets/logo.svg | 50 +++--- web/frontend/src/components/AppFooter.jsx | 4 +- .../src/components/AppFooter.test.jsx | 4 +- web/frontend/src/components/DashboardView.jsx | 27 ++- .../src/components/tabs/AnalyzeTab.jsx | 96 ++++++----- .../src/components/tabs/ResultsTab.jsx | 72 ++++---- web/frontend/src/styles.css | 154 +++++++++++++++--- 8 files changed, 288 insertions(+), 143 deletions(-) create mode 100644 web/frontend/src/assets/file.svg diff --git a/web/frontend/src/assets/file.svg b/web/frontend/src/assets/file.svg new file mode 100644 index 00000000..378739ee --- /dev/null +++ b/web/frontend/src/assets/file.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/frontend/src/assets/logo.svg b/web/frontend/src/assets/logo.svg index 189a846f..e66a3352 100644 --- a/web/frontend/src/assets/logo.svg +++ b/web/frontend/src/assets/logo.svg @@ -1,20 +1,30 @@ - - - - - - - - - - - - - - - - Resistance - Profiler - \ No newline at end of file + + + + + + + + + Res + + + + + + + + + + + + + + + + + + + + + diff --git a/web/frontend/src/components/AppFooter.jsx b/web/frontend/src/components/AppFooter.jsx index a8f106ab..4643e48f 100644 --- a/web/frontend/src/components/AppFooter.jsx +++ b/web/frontend/src/components/AppFooter.jsx @@ -24,13 +24,13 @@ export function AppFooter({ legalLink, contactEmail, cliVersion, webVersion }) { {(cliVersion || webVersion) && (
{cliVersion && ( - ResPro Core v{cliVersion} + respro CLI v{cliVersion} )} {cliVersion && webVersion && ( )} {webVersion && ( - ResPro WebApp v{webVersion} + respro WebApp v{webVersion} )}
)} diff --git a/web/frontend/src/components/AppFooter.test.jsx b/web/frontend/src/components/AppFooter.test.jsx index 25b06801..9eb8fec4 100644 --- a/web/frontend/src/components/AppFooter.test.jsx +++ b/web/frontend/src/components/AppFooter.test.jsx @@ -12,8 +12,8 @@ describe('AppFooter', () => { render(); expect(screen.queryByText('Legal notice')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /contact/i })).not.toBeInTheDocument(); - expect(screen.queryByText(/ResPro Core/)).not.toBeInTheDocument(); - expect(screen.queryByText(/ResPro WebApp/)).not.toBeInTheDocument(); + expect(screen.queryByText(/respro CLI/)).not.toBeInTheDocument(); + expect(screen.queryByText(/respro WebApp/)).not.toBeInTheDocument(); }); it('renders a mailto contact link only when contactEmail is set', () => { diff --git a/web/frontend/src/components/DashboardView.jsx b/web/frontend/src/components/DashboardView.jsx index 62dc17ae..77c91507 100644 --- a/web/frontend/src/components/DashboardView.jsx +++ b/web/frontend/src/components/DashboardView.jsx @@ -178,21 +178,6 @@ export function DashboardView({ return (
- {/* Hamburger toggle for the off-canvas sidebar. Hidden on desktop via - CSS (.mobile-menu-btn is display:none above --mobile-breakpoint). */} - - {/* Left rail only switches visible mode; all data lives in shared hook state. */}
@@ -277,23 +324,6 @@ export function AnalyzeTab({ {/* SINGLE SAMPLE submode */} {analyzeSubMode === 'single' ? (
-
-
-
- {PROFILE_MODES.map((mode) => ( - - ))} -
-
-
{activeProfileMode === 'vcf' ? (
@@ -553,38 +583,6 @@ export function AnalyzeTab({
{!batchSubmitted ? ( <> - {/* Mode sub-selector */} -
-
-
- - - -
-
-
- {/* File upload area */} {batchMode === 'vcf' ? (
diff --git a/web/frontend/src/components/tabs/ResultsTab.jsx b/web/frontend/src/components/tabs/ResultsTab.jsx index 128f8e8d..22d0df10 100644 --- a/web/frontend/src/components/tabs/ResultsTab.jsx +++ b/web/frontend/src/components/tabs/ResultsTab.jsx @@ -30,6 +30,15 @@ export function ResultsTab({ fetchComparisonData, clearComparison, }) { + const selectedResults = [...selectedResultIndices] + .map((i) => sessionResults[i]) + .filter(Boolean); + const selectedDbIds = new Set(selectedResults.map((r) => r.database_id || '')); + const selectedRefNames = new Set(selectedResults.map((r) => r.reference_name || '')); + const isSelectionComparable = selectedResults.length >= 2 + && selectedDbIds.size === 1 + && selectedRefNames.size === 1; + return (
@@ -37,6 +46,20 @@ export function ResultsTab({

Session results

All analysis outputs from this session. Results are cleared on page reload.

+ {sessionResults.length > 0 ? ( + + ) : null}
{sessionResults.length === 0 ? (

No results yet. Run an analysis to see results here.

@@ -46,7 +69,20 @@ export function ResultsTab({ - + @@ -104,32 +140,10 @@ export function ResultsTab({
+ 0 && selectedResultIndices.size === sessionResults.length} + onChange={(e) => { + if (e.target.checked) { + setSelectedResultIndices(new Set(sessionResults.map((_, i) => i))); + } else { + setSelectedResultIndices(new Set()); + } + }} + /> + Mode Sample Reference
-
- - -
+ {comparisonData && ( + <>
- {comparisonData && ( -
+

Comparison heatmap @@ -204,6 +219,7 @@ export function ResultsTab({

+ )} )} diff --git a/web/frontend/src/styles.css b/web/frontend/src/styles.css index 148110a8..c8320ffa 100644 --- a/web/frontend/src/styles.css +++ b/web/frontend/src/styles.css @@ -58,7 +58,7 @@ --neutral-border: #d3dde6; /* Webapp-only structural tokens (not used by the report) */ --topbar-logo-width: 380px; - --topbar-logo-height: 68px; + --topbar-logo-height: 55px; --topbar-link-icon-size: 32px; --sidebar-width: 118px; --sidebar-logo-gap: 5px; @@ -125,6 +125,7 @@ small { #root { min-height: 100vh; + min-height: 100dvh; background: transparent; } @@ -887,6 +888,7 @@ th { margin-left: var(--sidebar-width); width: auto; min-height: 100vh; + min-height: 100dvh; padding: 0; display: flex; flex-direction: column; @@ -1402,6 +1404,7 @@ th { .mutation-browser-tile { height: calc(100vh - 74px - 0.8rem - 1rem); + height: calc(100dvh - 74px - 0.8rem - 1rem); grid-template-rows: auto auto 1fr auto; overflow: hidden; } @@ -1510,10 +1513,21 @@ th { min-width: 0; } -.comparison-actions .analyze-primary, -.comparison-actions .comparison-clear-btn { - flex: 0 0 auto; - white-space: nowrap; +.comparison-actions .analyze-primary { + flex: 1 1 0; + min-width: 0; + white-space: normal; + text-align: center; +} + +.comparison-actions .comparison-secondary { + background-color: var(--neutral-text); + border-color: var(--neutral-text); +} + +.comparison-actions .comparison-secondary:hover { + background-color: var(--muted-strong); + border-color: var(--muted-strong); } .comparison-filters { @@ -1634,8 +1648,9 @@ th { .analyze-submode-controls { order: 1; display: inline-flex; - align-items: stretch; - gap: 0.35rem; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; } /* One/multiple-sample toggle — an animated sliding switch (track + knob). */ @@ -2027,6 +2042,66 @@ th { min-width: 0; } +/* ── Custom file input styling ───────────────────────────────────────── + Replace the browser-default "Choose File" control with a styled + drop-zone that matches the app's design tokens. */ +.profile-upload-row input[type="file"], +.profile-upload-row-batch-files input[type="file"] { + display: block; + width: 100%; + min-height: 2.75rem; + padding: 0.3rem 0.5rem; + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--paper-soft); + color: var(--muted); + font-size: var(--font-size-sm); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.profile-upload-row input[type="file"]:hover, +.profile-upload-row-batch-files input[type="file"]:hover { + border-color: var(--accent); + background: var(--accent-tint); +} + +.profile-upload-row input[type="file"]:disabled, +.profile-upload-row-batch-files input[type="file"]:disabled { + opacity: 0.55; + cursor: not-allowed; + border-style: solid; +} + +.profile-upload-row input[type="file"]::file-selector-button, +.profile-upload-row-batch-files input[type="file"]::file-selector-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--on-accent); + font-size: var(--font-size-button); + font-weight: 700; + padding: 0.3rem 0.7rem; + margin-right: 0.5rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.profile-upload-row input[type="file"]:hover::file-selector-button, +.profile-upload-row-batch-files input[type="file"]:hover::file-selector-button { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.profile-upload-row input[type="file"]:disabled::file-selector-button, +.profile-upload-row-batch-files input[type="file"]:disabled::file-selector-button { + cursor: not-allowed; + opacity: 1; +} + .profile-upload-row-batch-files { grid-template-columns: minmax(0, 1fr); } @@ -2054,6 +2129,10 @@ th { grid-column: 1 / -1; } +.database-algorithm-table th { + white-space: nowrap; +} + .database-algorithm-table td { vertical-align: top; } @@ -2188,8 +2267,8 @@ th { top: 0; left: 0; width: var(--sidebar-width); + height: 100vh; height: 100dvh; - min-height: 100vh; background: linear-gradient(180deg, #142236, #0f1a28); border-right: 1px solid #28384e; z-index: 30; @@ -2414,7 +2493,7 @@ th { border: none; border-radius: 0; border-bottom: 1px solid var(--line); - padding: 0.15rem 0.7rem; + padding: 0.5rem 0.7rem; margin-bottom: 0.8rem; display: flex; align-items: center; @@ -2552,25 +2631,21 @@ th.sortable-col { } /* ── Mobile hamburger toggle ────────────────────────────────────────────── - Hidden on desktop; revealed inside the ≤768px media block below. The - button is fixed top-left so it stays reachable above the top bar. */ + Hidden on desktop; revealed inside the ≤820px media block below. + Sits inside the top bar as a centered, borderless button. */ .mobile-menu-btn { display: none; - position: fixed; - top: 0.5rem; - left: 0.5rem; - z-index: 40; width: var(--mobile-tap-target); height: var(--mobile-tap-target); - padding: 0.55rem; - border: 1px solid var(--line-strong); + padding: 0.7rem; + border: none; border-radius: var(--radius-md); - background: var(--surface-strong); + background: transparent; color: var(--ink); flex-direction: column; justify-content: center; gap: 5px; - box-shadow: var(--shadow-sm); + cursor: pointer; } .mobile-menu-bar { @@ -2592,6 +2667,7 @@ th.sortable-col { .sidebar-rail { width: var(--sidebar-width); height: 100vh; + height: 100dvh; border-right: 1px solid #28384e; border-bottom: none; } @@ -2696,6 +2772,7 @@ th.sortable-col { .sidebar-rail { width: var(--sidebar-width); + height: 100vh; height: 100dvh; transform: translateX(-100%); transition: transform 0.22s ease; @@ -2730,17 +2807,26 @@ th.sortable-col { width: 100%; } - /* Add top padding so content clears the fixed hamburger button. */ + /* Add top padding so content clears the top bar. */ .top-bar { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - padding-left: calc(var(--mobile-tap-target) + 0.85rem); + grid-template-columns: var(--mobile-tap-target) minmax(0, 1fr) auto; + align-items: center; + padding-left: 0.45rem; + padding-right: 0.45rem; padding-top: 0.15rem; padding-bottom: 0.5rem; row-gap: 0.35rem; margin-bottom: 0.3rem; } + .mobile-menu-btn { + grid-column: 1; + grid-row: 1 / span 2; + justify-self: start; + align-self: center; + } + /* ── T3: top bar / brand block fluid on mobile ──────────────────────── Let the logo scale down rather than clip, and allow the brand block to wrap the DB selector / page links onto their own rows. */ @@ -2764,14 +2850,14 @@ th.sortable-col { } .brand-logo-wrap { - grid-column: 1; + grid-column: 2; grid-row: 1; margin-left: 0; order: initial; } .page-links { - grid-column: 2; + grid-column: 3; grid-row: 1; margin-left: auto; flex-shrink: 0; @@ -2779,7 +2865,7 @@ th.sortable-col { } .topbar-db-bar { - grid-column: 1 / -1; + grid-column: 2 / -1; grid-row: 2; width: 100%; min-width: 0; @@ -3013,7 +3099,7 @@ th.sortable-col { justify-content: space-between; align-items: center; flex-wrap: wrap; - padding: 0.35rem 0.6rem 0.35rem calc(var(--mobile-tap-target) + 0.85rem); + padding: 0.35rem 0.6rem 0.35rem 0.45rem; } .page-links { @@ -3119,7 +3205,10 @@ th.sortable-col { } .top-bar { - padding: 0.15rem 0.45rem 0.15rem calc(var(--mobile-tap-target) + 0.45rem); + padding-top: 0.15rem; + padding-bottom: 0.15rem; + padding-left: 0.45rem; + padding-right: 0.45rem; } .panel-stack { @@ -3283,6 +3372,17 @@ th.sortable-col { text-align: center; } +.results-download-btn { + align-self: flex-start; + white-space: nowrap; + flex-shrink: 0; + margin-left: auto; +} + +.full-width-tile > .workspace-output-header:first-child { + flex-wrap: nowrap; +} + .app-footer { display: flex; justify-content: center; From d7b16f99a69afd419c345c31c7f11bdb5838c01d Mon Sep 17 00:00:00 2001 From: jonas Date: Thu, 3 Sep 2026 15:52:17 +0200 Subject: [PATCH 3/3] logo update and visuals --- respro/report/static/report.css | 17 +++++++++++++++++ respro/report/templates/report.html.j2 | 2 +- web/frontend/src/assets/logo.svg | 25 +++++++++++++++---------- web/frontend/src/styles.css | 14 +++++++++----- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/respro/report/static/report.css b/respro/report/static/report.css index 93d823ed..a501e1e0 100644 --- a/respro/report/static/report.css +++ b/respro/report/static/report.css @@ -74,12 +74,15 @@ body { line-height: 1.55; background-color: var(--paper); min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; } .report-container { display: flex; flex-direction: column; min-height: 100vh; + min-height: 100dvh; background: var(--surface-strong); } @@ -802,6 +805,20 @@ body { } } +@media (max-width: 400px) { + .report-main { + padding: 0.75rem; + } + + .header-top { + padding: 0.6rem 0.75rem; + } + + .summary-tile { + padding: 0.85rem; + } +} + /* ─── All Mutations table ─────────────────────────────────────────────────── */ .mutation-toolbar { diff --git a/respro/report/templates/report.html.j2 b/respro/report/templates/report.html.j2 index 7bdbd231..5767c008 100644 --- a/respro/report/templates/report.html.j2 +++ b/respro/report/templates/report.html.j2 @@ -2,7 +2,7 @@ - + {{ context.title }}