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 session
RW
- {view === 'analysis' ? <>
Heimdall / Analysis workspace

Cross-TRE analysis

Build a request, run it across connected environments, and inspect disclosure-safe output.

Record-level data stays inside each TRE.Only aggregate statistics and model information pass through the gateway.
Step 1

Define your analysis

Genotype variables
{genotypeVariables.map((variable) => )}
Phenotype variables
{phenotypeVariables.map((variable) => )}
{isRegression ?
{isLinear ? : null}
: null}
Optional filters{filterRows.map((row, index) =>
updateFilter(index, 'value', event.target.value)} />
)}
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'}
) :
No results are waiting for release.
}
: <>
Heimdall / Governance

Audit logs

Per-site activity keyed by spec hash.

{audit.length ?
{audit.map((entry, index) => )}
TimeSiteSpec hashDecisionReleased
{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.

+
+ +
+ +
+ +
+ Record-level data stays inside each TRE. + Only aggregate statistics and model information pass through the gateway. +
+
+ +
+
+ + + + + +
+ +
+
+
+
+ Step 1 +

Define your analysis

+
+ +
+ + + +
+ Genotype variables +
+ {genotypeVariables.map((variable) => ( + + ))} +
+
+ +
+ Phenotype variables +
+ {phenotypeVariables.map((variable) => ( + + ))} +
+
+ + {isRegression ? ( +
+ + {isLinear ? ( + + ) : null} +
+ ) : null} + +
+ + +
+ +
+ Optional filters + {filterRows.map((row, index) => ( +
+ + + updateFilter(index, 'value', event.target.value)} /> +
+ ))} + +
+ + +
+ +
+
+
+ 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'} +
+
+ + +
+
+ )) :
No results are waiting for release.
} +
+ + ) : ( + <> +
+
+
Heimdall / Governance
+

Audit logs

+

Per-site activity keyed by spec hash.

+
+
+
+ {audit.length ? ( +
+ + + + + + {audit.map((entry, index) => ( + + + + + + + + ))} + +
TimeSiteSpec hashDecisionReleased
{entry.timestamp || entry.time}{entry.tre_id || entry.site}{entry.spec_hash}{entry.decision}{JSON.stringify(entry.released || {})}
+
+ ) :
No audit entries returned.
} +
+ + )} +
+ Output contains aggregates only + {notice || `API: ${API_BASE}`} +
+
+
+
+ ) }