diff --git a/.gitignore b/.gitignore index 71ef8ac..22a9745 100644 --- a/.gitignore +++ b/.gitignore @@ -18,14 +18,23 @@ flare/jobs/ flare/kits/ .local_fed/ .next +<<<<<<< HEAD +<<<<<<< HEAD +======= +======= +>>>>>>> b05bac27146e3cacfc9e8a99a06e67a2150ddf78 *.csv *.bed *.bim *.fam *_geno.* site*_nordik_sim.* +<<<<<<< HEAD .remote/ server/api_out/ frontend/.next/ frontend/AGENTS.md frontend/CLAUDE.md +>>>>>>> 1589cd5ef94e40e0c3866ba2b7a45973a101a81c +======= +>>>>>>> b05bac27146e3cacfc9e8a99a06e67a2150ddf78 diff --git a/data/ground_truth.json b/data/ground_truth.json index 1ebdd8d..5bcf7fd 100644 --- a/data/ground_truth.json +++ b/data/ground_truth.json @@ -236,6 +236,15 @@ "snp_rs013" ], "coef": { +<<<<<<< HEAD + "intercept": 89.2728860425527, + "age": 0.5055436521435493, + "sex": 4.203368841196912, + "bmi": 0.8062409982940054, + "ldl": 1.2189188961362305, + "snp_rs001": 2.3934157478351294, + "snp_rs007": -1.2816779284343325, +======= "intercept": 89.27288604255267, "age": 0.5055436521435472, "sex": 4.203368841196912, @@ -243,6 +252,7 @@ "ldl": 1.2189188961362498, "snp_rs001": 2.3934157478351223, "snp_rs007": -1.2816779284343316, +>>>>>>> fffe7185c787e67d9476d0f02c3d2469b8e2b5f0 "snp_rs013": 0.9002997078401026 } }, diff --git a/frontend/.env.example b/frontend/.env.example index ee17ccf..0fb612a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080 diff --git a/frontend/README.md b/frontend/README.md index 982cd4b..3470256 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -10,23 +10,25 @@ npm install npm run dev ``` -The UI uses Webpack mode because the native Windows SWC binary may be unavailable. Set `NEXT_PUBLIC_API_BASE_URL` when the API is not running at `http://localhost:8500`: +The UI uses Webpack mode because the native Windows SWC binary may be unavailable. Browser requests use the same-origin `/api` proxy, so the browser does not need CORS access to the Python API. The proxy targets `http://127.0.0.1:8080` by default. Set `BACKEND_API_URL` in the frontend environment to override it: ```powershell -$env:NEXT_PUBLIC_API_BASE_URL = "http://localhost:8500" +$env:BACKEND_API_URL = "http://127.0.0.1:8080" ``` -The expected API surface is: +The current backend API surface is: -- `GET /metadata` — canonical variables, safe projects, examples, and TRE status. -- `GET /examples/{filename}` — load an example `AnalysisSpec`. -- `POST /run` — submit an `AnalysisSpec`; returns a run ID. -- `GET /run/{id}` — poll status and read the merged server result. -- `GET /overseer` — read flagged results. -- `POST /overseer/{spec_hash}/approve` and `/reject` — release or reject a queued result. -- `GET /audit` — read per-site audit records. +- `GET /health` +- `GET /allele-frequency?variant=...&project_id=...&min_cell_size=...` +- `POST /linear-regression` with `features`, `target`, `project_id`, `filters`, and `min_cell_size`. +- `POST /logistic-regression` with `features`, `target`, `project_id`, `filters`, and `min_cell_size`. -The result renderer follows the server output fields: `coverage`, `sites_missing`, `n_per_site`, `rejected_per_site`, and `stats` containing allele frequencies, genotype counts, summary statistics, OLS coefficients, and FedAvg history. The TRE list is supplied by the API; the frontend does not read site CSVs or import TRE applications. +The Next.js route at `app/api/[...path]/route.js` forwards those requests server-side, avoiding browser CORS. Metadata, example loading, run polling, overseer, and audit routes are not exposed by the current backend, so the UI uses static variable/project choices and the existing example selector includes a `logistic_regression` mode for the new endpoint. -(From the docs, I mean the global README) -The Python backend and local TREs are started from the repository root. For the simulated TRE path, run `python scripts/dev_tres.py`, then start the API on port 8500. +Start the Python API from the repository root on port 8080, then start this frontend: + +```powershell +python -m uvicorn server.api:app --host 127.0.0.1 --port 8080 --workers 1 +cd frontend +npm run dev +``` diff --git a/frontend/app/api/[...path]/route.js b/frontend/app/api/[...path]/route.js new file mode 100644 index 0000000..66f4438 --- /dev/null +++ b/frontend/app/api/[...path]/route.js @@ -0,0 +1,23 @@ +const backendBase = process.env.BACKEND_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8080' + +async function forward(request, context) { + const { path = [] } = await context.params + const target = new URL(`/${path.join('/')}`, `${backendBase.replace(/\/$/, '')}/`) + target.search = new URL(request.url).search + + const headers = new Headers() + const contentType = request.headers.get('content-type') + if (contentType) headers.set('content-type', contentType) + + const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer() + const response = await fetch(target, { method: request.method, headers, body, cache: 'no-store' }) + const responseHeaders = new Headers() + const responseType = response.headers.get('content-type') + if (responseType) responseHeaders.set('content-type', responseType) + + return new Response(response.body, { status: response.status, headers: responseHeaders }) +} + +export const GET = forward +export const POST = forward +export const OPTIONS = () => new Response(null, { status: 204 }) diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx index d3da30a..5cd9624 100644 --- a/frontend/app/page.jsx +++ b/frontend/app/page.jsx @@ -8,28 +8,44 @@ import { } from 'lucide-react' import { ResultView } from './result-view' -const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8500' -const fallbackMetadata = { +const API_BASE = '/api' +const staticMetadata = { projects: [{ id: 'ncfh-2026-demo' }, { id: 'hackathon-test' }], variables: [ - { name: 'age', type: 'continuous' }, { name: 'sex', type: 'binary' }, - { name: 'bmi', type: 'continuous' }, { name: 'sbp', type: 'continuous' }, { name: 'case', type: 'binary' }, + { name: 'age', type: 'continuous' }, + { name: 'sex', type: 'binary' }, + { name: 'bmi', type: 'continuous' }, + { name: 'sbp', type: 'continuous' }, + { name: 'case', type: 'binary' }, ...Array.from({ length: 20 }, (_, index) => ({ name: `snp_rs${String(index + 1).padStart(3, '0')}`, type: 'genotype' })), ], examples: ['allele_freq.json', 'allele_freq_filtered.json', 'allele_freq_rejected.json', 'fed_linreg.json', 'fed_logreg.json'], - sites: [{ tre_id: 'hunt', adapter: 'rest', region: 'NO' }, { tre_id: 'gefion', adapter: 'datashield', region: 'NO' }, { tre_id: 'brev', adapter: 'sql', region: 'NO' }], + sites: [ + { tre_id: 'hunt', adapter: 'rest', region: 'NO' }, + { tre_id: 'gefion', adapter: 'datashield', region: 'NO' }, + { tre_id: 'brev', adapter: 'sql', region: 'NO' } + ], + analysis_types: [ + 'allele_freq', + 'fed_stats', + 'fed_linreg', + 'fed_logreg', + ], } async function api(path, options) { - const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) } }) + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) }, + }) const body = await response.json().catch(() => ({})) - if (!response.ok) throw new Error(body.detail || body.message || `API returned ${response.status}`) + if (!response.ok) throw new Error(body.detail || body.error || body.message || `API returned ${response.status}`) return body } export default function Home() { const [view, setView] = useState('analysis') - const [metadata, setMetadata] = useState({ ...fallbackMetadata, sites: [] }) + const [metadata, setMetadata] = useState(staticMetadata) const [analysisType, setAnalysisType] = useState('allele_freq') const [variables, setVariables] = useState(['snp_rs001']) const [outcome, setOutcome] = useState('sbp') @@ -46,31 +62,450 @@ export default function Home() { const [running, setRunning] = useState(false) const [metadataError, setMetadataError] = useState(false) - useEffect(() => { api('/metadata').then((body) => setMetadata({ ...body, sites: (body.sites || []).map((site) => ({ ...site, online: site.online ?? site.status === 'ok' })) })).catch(() => { setMetadataError(true); setMetadata((current) => ({ ...current, sites: [] })) }) }, []) - useEffect(() => { - if (!runId) return undefined - let timer - const poll = async () => { try { const next = await api(`/run/${runId}`); setRun(next); if (!['queued', 'running', 'pending'].includes(next.status)) { setRunning(false); clearInterval(timer) } } catch (error) { setNotice(error.message); setRunning(false); clearInterval(timer) } } - timer = setInterval(poll, 1200); poll() - return () => clearInterval(timer) - }, [runId]) - useEffect(() => { if (view === 'overseer') api('/overseer').then((body) => setPending(body.items || body || [])).catch((error) => setNotice(error.message)); if (view === 'audit') api('/audit').then((body) => setAudit(body.items || body || [])).catch((error) => setNotice(error.message)) }, [view]) - + const binaryVariables = useMemo( + () => metadata.variables.filter((variable) => variable.type === 'binary'), + [metadata] + ) const genotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type === 'genotype'), [metadata]) const phenotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type !== 'genotype'), [metadata]) + const isLinear = analysisType === 'fed_linreg' const isLogistic = analysisType === 'fed_logreg' const isRegression = isLinear || isLogistic - const outcomeVariables = isLogistic ? phenotypeVariables.filter((variable) => variable.type === 'binary') : phenotypeVariables - function chooseLogistic() { setAnalysisType('fed_logreg'); setOutcome((current) => (phenotypeVariables.find((variable) => variable.name === current)?.type === 'binary' ? current : (phenotypeVariables.find((variable) => variable.type === 'binary' && !variables.includes(variable.name))?.name || current))) } - function toggleVariable(name) { setVariables((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]) } - function updateFilter(index, key, value) { setFilterRows((rows) => rows.map((row, rowIndex) => rowIndex === index ? { ...row, [key]: value } : row)) } - function loadExample(value) { setExample(value); if (!value) return; api(`/examples/${value}`).then((spec) => { setAnalysisType(spec.analysis_type); setVariables(spec.variables || []); setProjectId(spec.project_id); setOutcome(spec.outcome || 'sbp'); setMinCell(spec.min_cell_size || 5); setRounds(spec.fedavg_rounds || 0); const loaded = Object.entries(spec.filters || {}).flatMap(([variable, conditions]) => Object.entries(conditions).map(([op, filterValue]) => ({ variable, op, value: String(filterValue) }))); setFilterRows(loaded.length ? loaded : [{ variable: '', op: '==', value: '' }]) }).catch((error) => setNotice(error.message)) } - function buildFilters() { return filterRows.reduce((filters, row) => { if (!row.variable || row.value === '') return filters; const value = row.op === 'in' ? row.value.split(',').map((item) => { const trimmed = item.trim(); return Number.isNaN(Number(trimmed)) ? trimmed : Number(trimmed) }) : (Number.isNaN(Number(row.value)) ? row.value : Number(row.value)); return { ...filters, [row.variable]: { ...(filters[row.variable] || {}), [row.op]: value } } }, {}) } - async function submitRun(event) { event.preventDefault(); setRunning(true); setRun(null); setNotice('Submitting analysis to Heimdall...'); try { const body = await api('/run', { method: 'POST', body: JSON.stringify({ analysis_type: analysisType, variables, filters: buildFilters(), min_cell_size: Number(minCell), project_id: projectId, ...(isRegression ? { outcome } : {}), ...(isLinear ? { fedavg_rounds: Number(rounds) } : {}) }) }); setRunId(body.run_id || body.id); setNotice('Run submitted. Waiting for TRE responses...') } catch (error) { setNotice(error.message); setRunning(false) } } - async function decide(item, decision) { try { await api(`/overseer/${item.id || item.spec_hash}/${decision}`, { method: 'POST', body: JSON.stringify({ note: item.note || '' }) }); setPending((items) => items.filter((entry) => entry !== item)); setNotice(`Result ${decision}d.`) } catch (error) { setNotice(error.message) } } - - return HEIMDALLFederated analysis gateway Secure sessionRW - {view === 'analysis' ? <>Heimdall / Analysis workspaceCross-TRE analysisBuild a request, run it across connected environments, and inspect disclosure-safe output. run && navigator.clipboard?.writeText(JSON.stringify(run, null, 2))}> Copy result JSONRecord-level data stays inside each TRE.Only aggregate statistics and model information pass through the gateway. setAnalysisType('allele_freq')}>Allele frequencyFederated analysis setAnalysisType('fed_linreg')}>Linear regressionFederated learningLogistic regressionFederated learningStep 1Define your analysis loadExample(event.target.value)}>Load example...{metadata.examples.map((item) => {item})}Safe project setProjectId(event.target.value)}>{metadata.projects.map((project) => {project.id})}Genotype variables{genotypeVariables.map((variable) => toggleVariable(variable.name)} />{variable.name})}Phenotype variables{phenotypeVariables.map((variable) => toggleVariable(variable.name)} />{variable.name})}{isRegression ? Outcome setOutcome(event.target.value)}>{outcomeVariables.map((variable) => {variable.name})}{isLinear ? FedAvg rounds setRounds(event.target.value)} />0 = exact aggregation : null} : null}Minimum cell size setMinCell(event.target.value)} />Selected variablesOptional filters{filterRows.map((row, index) => updateFilter(index, 'variable', event.target.value)}>Variable{metadata.variables.map((variable) => {variable.name})} updateFilter(index, 'op', event.target.value)}>equalsdoes not equalgreater or equalgreater thanless or equalless thanin list updateFilter(index, 'value', event.target.value)} />)} setFilterRows((rows) => [...rows, { variable: '', op: '==', value: '' }])}>+ Add filter{running ? : }{running ? 'Running analysis...' : 'Run federated analysis'}Step 2Connected TREs {metadata.sites.filter((site) => site.online).length || '—'} online{metadata.sites.length ? metadata.sites.map((site) => {site.tre_id || site.name}{site.adapter || 'adapter'} · {site.region || 'connected'}{site.online ? <> Ready> : 'Status unknown'}) : Site metadata is unavailable until the API connects.} Disclosure policy: k ≥ {minCell} · TLS enabled{run ? : {notice || 'Your federated result will appear here.'}Every run gets a visible spec hash and disclosure decision.}> : view === 'overseer' ? <>Heimdall / GovernanceOverseer queueReview outputs held for a human release decision.{pending.length ? pending.map((item) => Spec hash: {item.spec_hash}{item.reason || item.reasons?.join(', ') || 'Disclosure review required'}{item.tre_id || 'Federated result'} decide(item, 'approve')}> Approve decide(item, 'reject')}> Reject) : No results are waiting for release.}> : <>Heimdall / GovernanceAudit logsPer-site activity keyed by spec hash.{audit.length ? TimeSiteSpec hashDecisionReleased{audit.map((entry, index) => {entry.timestamp || entry.time}{entry.tre_id || entry.site}{entry.spec_hash}{entry.decision}{JSON.stringify(entry.released || {})})} : No audit entries returned.}>} - + + useEffect(() => { + if ( + analysisType === 'fed_logreg' && + !binaryVariables.some((variable) => variable.name === outcome) + ) { + setOutcome(binaryVariables[0]?.name || '') + } + }, [analysisType, binaryVariables, outcome]) + + useEffect(() => { + api('/metadata') + .then((body) => { + setMetadata({ + ...staticMetadata, + ...body, + sites: (body.sites || []).map((site) => ({ ...site, online: site.online ?? site.status === 'ok' })), + }) + setProjectId(body.projects?.[0]?.id || 'ncfh-2026-demo') + setMetadataError(false) + }) + .catch(() => setMetadataError(true)) + }, []) + + useEffect(() => { + if (!runId) return + + let isActive = true + let timeoutId + + const poll = async () => { + try { + const next = await api(`/run/${runId}`) + if (!isActive) return + + setRun(next) + if (['queued', 'running', 'pending'].includes(next.status)) { + timeoutId = setTimeout(poll, 1200) + } else { + setRunning(false) + } + } catch (error) { + if (!isActive) return + setNotice(error.message) + setRunning(false) + } + } + + poll() + + return () => { + isActive = false + clearTimeout(timeoutId) + } + }, [runId]) + + useEffect(() => { + if (view === 'overseer') api('/overseer').then((body) => setPending(body.items || [])).catch((error) => setNotice(error.message)) + if (view === 'audit') api('/audit').then((body) => setAudit(body.items || [])).catch((error) => setNotice(error.message)) + }, [view]) + + function toggleVariable(name) { + setVariables((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]) + } + + function updateFilter(index, key, value) { + setFilterRows((rows) => rows.map((row, rowIndex) => rowIndex === index ? { ...row, [key]: value } : row)) + } + + function loadExample(value) { + setExample(value) + if (!value) return + api(`/examples/${value}`).then((spec) => { + setAnalysisType(spec.analysis_type) + setVariables(spec.variables || []) + setProjectId(spec.project_id) + setOutcome(spec.outcome || 'sbp') + setMinCell(spec.min_cell_size || 5) + setRounds(spec.fedavg_rounds || 0) + const loaded = Object.entries(spec.filters || {}).flatMap(([variable, conditions]) => Object.entries(conditions).map(([op, filterValue]) => ({ variable, op, value: String(filterValue) }))) + setFilterRows(loaded.length ? loaded : [{ variable: '', op: '==', value: '' }]) + }).catch((error) => setNotice(error.message)) + } + + function buildFilters() { + return filterRows.reduce((filters, row) => { + if (!row.variable || row.value === '') return filters + const value = row.op === 'in' + ? row.value.split(',').map((item) => { + const trimmed = item.trim() + return Number.isNaN(Number(trimmed)) ? trimmed : Number(trimmed) + }) + : (Number.isNaN(Number(row.value)) ? row.value : Number(row.value)) + return { ...filters, [row.variable]: { ...(filters[row.variable] || {}), [row.op]: value } } + }, {}) + } + + async function submitRun(event) { + event.preventDefault() + setRunning(true) + setRun(null) + setRunId('') + setNotice('Submitting analysis...') + try { + const body = await api('/run', { + method: 'POST', + body: JSON.stringify({ + analysis_type: analysisType, + variables, + filters: buildFilters(), + min_cell_size: Number(minCell), + project_id: projectId, + ...(isRegression ? { outcome } : {}), + ...(isLinear ? { fedavg_rounds: Number(rounds) } : {}), + }), + }) + setRunId(body.run_id || body.id) + setNotice('Run submitted. Waiting for TRE responses...') + } catch (error) { + setNotice(error.message) + setRunning(false) + } + } + + async function decide(item, decision) { + try { + await api(`/overseer/${item.spec_hash || item.id}/${decision}`, { method: 'POST', body: JSON.stringify({ note: '', by: 'researcher' }) }) + setPending((items) => items.filter((entry) => (entry.spec_hash || entry.id) !== (item.spec_hash || item.id))) + setNotice(`Result ${decision}d.`) + } catch (error) { + setNotice(error.message) + } + } + + return ( + + + + + HEIMDALLFederated analysis gateway + + + Secure session + + RW + + + + + + + + {view === 'analysis' ? ( + <> + + + Heimdall / Analysis workspace + Cross-TRE analysis + Build a request, run it across connected environments, and inspect disclosure-safe output. + + run && navigator.clipboard?.writeText(JSON.stringify(run, null, 2))} + > + Copy result JSON + + + + + + + Record-level data stays inside each TRE. + Only aggregate statistics and model information pass through the gateway. + + + + + + setAnalysisType('allele_freq')} + > + + + Allele frequency + Federated analysis + + + + setAnalysisType('fed_linreg')} + > + + + Linear regression + Federated learning + + + + setAnalysisType('fed_logreg')} + > + + + Logistic regression + Federated learning + + + + + + + + + Step 1 + Define your analysis + + loadExample(event.target.value)}> + Load example... + {metadata.examples.map((item) => {item})} + + + + + Safe project + setProjectId(event.target.value)}> + {metadata.projects.map((project) => {project.id})} + + + + + Genotype variables + + {genotypeVariables.map((variable) => ( + + toggleVariable(variable.name)} /> + {variable.name} + + ))} + + + + + Phenotype variables + + {phenotypeVariables.map((variable) => ( + + toggleVariable(variable.name)} /> + {variable.name} + + ))} + + + + {isRegression ? ( + + + Outcome + setOutcome(event.target.value)}> + {(isLogistic ? binaryVariables : phenotypeVariables).map((variable) => ( + {variable.name} + ))} + + + {isLinear ? ( + + FedAvg rounds + setRounds(event.target.value)} /> + 0 = exact aggregation + + ) : null} + + ) : null} + + + + Minimum cell size + setMinCell(event.target.value)} /> + + + Selected variables + + + + + + Optional filters + {filterRows.map((row, index) => ( + + updateFilter(index, 'variable', event.target.value)}> + Variable + {metadata.variables.map((variable) => ( + {variable.name} + ))} + + updateFilter(index, 'op', event.target.value)}> + equals + does not equal + greater or equal + greater than + less or equal + less than + in list + + updateFilter(index, 'value', event.target.value)} /> + + ))} + setFilterRows((rows) => [...rows, { variable: '', op: '==', value: '' }])}>+ Add filter + + + + {running ? : } + {running ? 'Running analysis...' : 'Run federated analysis'} + + + + + + + Step 2 + Connected TREs + + {metadata.sites.filter((site) => site.online).length || '—'} online + + + {metadata.sites.length ? metadata.sites.map((site) => ( + + + + {site.tre_id || site.name} + {site.adapter || 'adapter'} · {site.region || 'connected'} + + + {site.online ? <> Ready> : 'Status unknown'} + + + )) : Site metadata is unavailable until the API connects.} + + Disclosure policy: k ≥ {minCell} · TLS enabled + + + + + + {run ? : ( + + + {notice || 'Your federated result will appear here.'} + Every run gets a visible spec hash and disclosure decision. + + )} + + > + ) : view === 'overseer' ? ( + <> + + + Heimdall / Governance + Overseer queue + Review outputs held for a human release decision. + + + + {pending.length ? pending.map((item) => ( + + + Spec hash: {item.spec_hash} + {item.reason || item.reasons?.join(', ') || 'Disclosure review required'} + {item.tre_id || 'Federated result'} + + + decide(item, 'approve')}> Approve + decide(item, 'reject')}> Reject + + + )) : No results are waiting for release.} + + > + ) : ( + <> + + + Heimdall / Governance + Audit logs + Per-site activity keyed by spec hash. + + + + {audit.length ? ( + + + + TimeSiteSpec hashDecisionReleased + + + {audit.map((entry, index) => ( + + {entry.timestamp || entry.time} + {entry.tre_id || entry.site} + {entry.spec_hash} + {entry.decision} + {JSON.stringify(entry.released || {})} + + ))} + + + + ) : No audit entries returned.} + + > + )} + + + + + ) }
Build a request, run it across connected environments, and inspect disclosure-safe output.
Review outputs held for a human release decision.
Per-site activity keyed by spec hash.