From e4b36f94023ecbeee3b4319285cb93ad42f6bd11 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 11:40:12 +0100 Subject: [PATCH 01/11] feat: update .gitignore to ignore .next --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b8f2701..afe1623 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ notes/ flare/jobs/ flare/kits/ .local_fed/ +.next From 78b6254224194b694ccc8d05d5a20525f9ad4ec3 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 11:44:12 +0100 Subject: [PATCH 02/11] feat: update AGENTS.md with researcher UI/frontend details --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9f6640b..223ced3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,10 +23,11 @@ Implemented: | Harmonisation | `harmonisation/canonical.yaml` (unlisted site ⇒ local name = canonical name) | | FLARE jobs | `flare/app/` — `controller.py`/`executor.py` (allele_freq, fed_stats, exact fed_linreg), `linreg_controller.py`/`linreg_executor.py`/`linreg.py` (FedAvg fed_linreg). `scripts/build_job.py` assembles a job folder; `flare/jobs/` is generated and gitignored | | Server side | `server/aggregate.py` (associative merge), `server/disclosure_check.py`, `server/overseer_queue.py` (CLI); outputs under `server/out/` (gitignored, `SERVER_OUT` env) | +| Researcher UI | `frontend/` — Next.js researcher, overseer, and audit workspace; the API surface documented in `frontend/README.md` | | Ops scripts | `scripts/local_federation.sh` (real FLARE, no Docker), `scripts/provision.sh`, `scripts/onboard_tre.sh`, `scripts/start_server.sh`, `scripts/start_client.sh`, `scripts/run_job.py` (`--mode simulator|prod`, `--fedavg`), `scripts/run_local.py`, `scripts/verify.py`, `scripts/scale_sim.py` → `docs/scaling.png` | | Tests | `tests/` — 31 fast + 3 FLARE-simulator (`-m slow`) | -A real (non-simulator) FLARE federation — provisioned server + clients as separate processes over mTLS — runs on one machine with `scripts/local_federation.sh`; all example specs, the overseer flow and a dead-client run were verified on it. Not implemented: UI. Not yet run anywhere: the Docker path (`scripts/up.sh`, images) — no Docker on the development machine. +A real (non-simulator) FLARE federation — provisioned server + clients as separate processes over mTLS — runs on one machine with `scripts/local_federation.sh`; all example specs, the overseer flow and a dead-client run were verified on it. The Next.js UI is implemented in `frontend/`; the Python API service exposing its documented contract remains separate. Not yet run anywhere: the Docker path (`scripts/up.sh`, images) — no Docker on the development machine. Known discrepancies — do not treat these as existing: From b94369113c112d9d73e2752835032b12a89110fa Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 11:45:11 +0100 Subject: [PATCH 03/11] feat: bacth-commiting the first iteration of the UI --- frontend/.env.example | 1 + frontend/README.md | 32 +++ frontend/app/globals.css | 35 +++ frontend/app/layout.jsx | 14 ++ frontend/app/page.jsx | 84 +++++++ frontend/package-lock.json | 502 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 17 ++ 7 files changed, 685 insertions(+) create mode 100644 frontend/.env.example create mode 100644 frontend/README.md create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.jsx create mode 100644 frontend/app/page.jsx create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ee17ccf --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..36b1189 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# Bifrost Next.js UI + +A Next.js interface for the Bifrost federated analysis API. It is intentionally isolated under `frontend/` from the Python TRE and FLARE code. + +## Run + +```powershell +cd frontend +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`: + +```powershell +$env:NEXT_PUBLIC_API_BASE_URL = "http://localhost:8500" +``` + +The expected 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. + +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. + +(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. diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..9c2756f --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,35 @@ +@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Public+Sans:wght@400;500;600;700&display=swap'); + +:root { --navy: #003087; --deep: #00205b; --blue: #005ea8; --sky: #eaf4fb; --ink: #172b4d; --muted: #60738d; --line: #d9e2ec; --bg: #f4f7fa; --green: #087f5b; --green-bg: #e6f5ef; --yellow: #f9c642; } +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); font-family: 'Public Sans', sans-serif; font-size: 14px; } +button, input, select { font: inherit; } +button { cursor: pointer; } +.shell { min-height: 100vh; background: linear-gradient(135deg, #f7fafc 0%, #f1f5f9 70%, #e9f2f8 100%); } +.topbar { height: 72px; padding: 0 36px; display: flex; justify-content: space-between; align-items: center; background: var(--navy); color: #fff; box-shadow: 0 2px 7px #001d4933; } +.brand-lockup, .top-actions, .secure-pill, .brand-lockup > div, .nav-item, .site-row, .site-state, .notice, .notice > div, .analysis-switcher button, .analysis-switcher button > span, .connection-count, .result-status, .safe-tag, .page-footer span { display: flex; align-items: center; } +.brand-lockup { gap: 12px; }.brand-mark { width: 37px; height: 37px; display: grid; place-items: center; background: var(--yellow); color: var(--deep); }.brand-lockup strong { font-family: 'Barlow Condensed'; font-size: 25px; letter-spacing: 1px; line-height: 20px; }.brand-lockup span { display: block; font-size: 11px; opacity: .76; margin-top: 3px; }.top-actions { gap: 18px; }.secure-pill { gap: 7px; border: 1px solid #ffffff4d; padding: 7px 10px; font-size: 12px; }.icon-button { border: 0; background: none; color: white; padding: 4px; }.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: #2f7fbd; font-size: 12px; font-weight: 700; } +.workspace { display: flex; max-width: 1600px; margin: 0 auto; }.sidebar { width: 234px; min-height: calc(100vh - 72px); display: flex; flex-direction: column; padding: 30px 16px 22px; background: #fff; border-right: 1px solid var(--line); }.side-label, .eyebrow, .panel-kicker { color: var(--muted); text-transform: uppercase; letter-spacing: 1.1px; font-size: 10px; font-weight: 700; }.side-label { padding: 0 12px 11px; }.nav-item { width: 100%; gap: 11px; padding: 12px; margin: 2px 0; border: 0; color: #48617d; background: transparent; text-align: left; font-weight: 600; }.nav-item span { margin-left: auto; font-size: 11px; color: #8da0b5; }.nav-item.active { background: var(--sky); color: var(--navy); border-left: 3px solid var(--blue); padding-left: 9px; }.sidebar-bottom { margin-top: auto; padding: 0 12px; }.system-status { color: var(--green); font-size: 12px; font-weight: 600; gap: 8px; }.system-status i, .connection-count i { width: 7px; height: 7px; background: #18a36f; border-radius: 50%; }.sidebar-bottom small { display: block; color: #8b9bad; font-size: 10px; padding: 8px 0 0 15px; } +.content { flex: 1; min-width: 0; padding: 37px 42px 28px; }.page-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 26px; }.eyebrow { color: var(--blue); margin-bottom: 5px; }h1, h2 { font-family: 'Barlow Condensed', sans-serif; margin: 0; color: var(--deep); }h1 { font-size: 38px; letter-spacing: .2px; }h2 { font-size: 23px; }.page-heading p, .results-heading p { color: var(--muted); margin: 6px 0 0; }.outline-button { display: flex; align-items: center; gap: 8px; background: white; border: 1px solid #b8c8d9; color: var(--navy); padding: 10px 14px; font-weight: 600; }.notice { gap: 12px; padding: 13px 15px; margin-bottom: 20px; color: var(--deep); background: #fff7dc; border: 1px solid #f0d47c; }.notice > svg { color: #a66d00; }.notice div { display: block; }.notice strong { font-size: 12px; margin-right: 5px; }.notice span { font-size: 12px; color: #6f5a22; }.notice button { margin-left: auto; border: 0; background: none; font-size: 21px; color: #9b7a2a; } +.analysis-switcher { display: flex; gap: 10px; margin-bottom: 19px; }.analysis-switcher button { flex: 0 1 330px; gap: 11px; padding: 12px 14px; border: 1px solid var(--line); background: #fff; color: var(--ink); text-align: left; }.analysis-switcher button.selected { border: 2px solid var(--blue); padding: 11px 13px; box-shadow: 0 2px 10px #005ea81a; }.analysis-switcher button > span:nth-child(2) { display: block; flex: 1; }.analysis-switcher strong, .analysis-switcher small { display: block; }.analysis-switcher small { color: var(--muted); font-size: 11px; margin-top: 2px; }.switch-icon { width: 30px; height: 30px; justify-content: center; }.switch-icon.blue { background: var(--sky); color: var(--blue); }.switch-icon.green { background: var(--green-bg); color: var(--green); } +.grid-layout { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, .9fr); gap: 18px; }.panel, .results-card { background: #fff; border: 1px solid var(--line); box-shadow: 0 2px 5px #1e42600a; }.panel { padding: 22px; }.panel-heading, .results-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }.panel-heading > svg { color: #8092a7; }.panel-kicker { display: block; color: var(--blue); margin-bottom: 4px; }.setup-panel label { display: block; color: #415a75; font-size: 12px; font-weight: 700; margin-top: 17px; }.setup-panel input, .setup-panel select { width: 100%; margin-top: 7px; padding: 10px 11px; border: 1px solid #b9c8d7; color: var(--ink); background: #fff; outline-color: var(--blue); }.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; }.helper { display: flex; gap: 7px; align-items: center; margin-top: 16px; padding: 10px; color: #42627c; background: #f2f7fb; font-size: 11px; }.token-input { display: flex; flex-wrap: wrap; gap: 6px; min-height: 40px; padding: 6px; margin-top: 7px; border: 1px solid #b9c8d7; }.token-input span { padding: 5px 7px; color: var(--navy); background: var(--sky); font-size: 11px; font-weight: 500; }.token-input button { border: 0; color: var(--blue); background: transparent; font-size: 18px; }.run-button { width: 100%; display: flex; justify-content: center; align-items: center; gap: 9px; margin-top: 20px; padding: 12px; border: 0; background: var(--navy); color: #fff; font-weight: 700; }.run-button:hover { background: var(--blue); }.run-button:disabled { opacity: .7; cursor: wait; }.spin { animation: spin 1s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } } +.connection-count { gap: 6px; color: var(--green); font-size: 11px; font-weight: 700; }.site-list { margin-top: 21px; }.site-row { gap: 10px; padding: 15px 0; border-top: 1px solid #edf1f5; }.site-icon { display: grid; place-items: center; width: 34px; height: 34px; background: #f0f5f9; color: var(--blue); }.site-info { flex: 1; min-width: 0; }.site-info strong, .site-info span { display: block; }.site-info strong { font-size: 12px; }.site-info span { margin-top: 3px; color: var(--muted); font-size: 10px; }.site-state { gap: 4px; color: var(--green); font-size: 10px; }.site-footer { display: flex; gap: 7px; align-items: center; margin-top: 10px; padding-top: 13px; border-top: 1px solid #edf1f5; color: var(--muted); font-size: 10px; } +.results-section { margin-top: 25px; }.results-heading { margin-bottom: 12px; }.results-heading h2 { font-size: 26px; }.result-status { gap: 5px; padding: 6px 9px; color: var(--green); background: var(--green-bg); font-size: 11px; font-weight: 700; white-space: nowrap; }.results-card { overflow: hidden; }.result-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); }.stat-card { min-height: 108px; padding: 17px 20px; border-right: 1px solid var(--line); }.stat-card:last-child { border-right: 0; }.stat-card span, .stat-card small { display: block; color: var(--muted); }.stat-card span { font-size: 11px; }.stat-card strong { display: block; color: var(--deep); font-family: 'Barlow Condensed'; font-size: 30px; line-height: 33px; }.stat-card small { font-size: 10px; }.stat-card.blue-accent strong { color: var(--blue); }.stat-card.green-accent strong { color: var(--green); }.table-wrap { overflow-x: auto; }table { width: 100%; border-collapse: collapse; text-align: left; }th { padding: 12px 20px; color: #75879b; background: #f8fafc; text-transform: uppercase; letter-spacing: .7px; font-size: 9px; }td { padding: 14px 20px; color: #49617a; border-top: 1px solid #edf1f5; font-size: 12px; white-space: nowrap; }td strong { color: var(--deep); }.bar-value { display: flex; align-items: center; gap: 8px; }.bar-value span { display: inline-block; height: 6px; min-width: 15px; background: var(--blue); }.safe-tag { gap: 4px; color: var(--green); font-size: 10px; font-weight: 700; }.page-footer { display: flex; justify-content: space-between; gap: 10px; padding-top: 22px; color: #7a8da2; font-size: 10px; }.page-footer span { gap: 6px; } +@media (max-width: 850px) { .sidebar { width: 60px; padding: 22px 8px; }.sidebar .side-label, .nav-item span, .sidebar-bottom { display: none; }.nav-item { justify-content: center; padding: 12px 5px; }.nav-item.active { padding-left: 5px; }.content { padding: 28px 20px; } } +@media (max-width: 620px) { .topbar { height: 62px; padding: 0 16px; }.brand-lockup span, .secure-pill { display: none; }.workspace { display: block; }.sidebar { display: none; }.content { padding: 24px 14px; }.page-heading { align-items: flex-start; flex-direction: column; }.page-heading h1 { font-size: 34px; }.outline-button { align-self: stretch; justify-content: center; }.analysis-switcher { flex-direction: column; }.analysis-switcher button { max-width: none; flex-basis: auto; }.grid-layout { grid-template-columns: 1fr; }.result-stats { grid-template-columns: 1fr; }.stat-card { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }.stat-card:last-child { border-bottom: 0; }.notice { align-items: flex-start; }.notice span { display: block; margin-top: 3px; }.page-footer { flex-direction: column; } } +.example-select { width: auto !important; margin: 0 !important; padding: 7px 9px !important; font-size: 11px; } +fieldset { margin: 17px 0 0; padding: 12px; border: 1px solid #d9e2ec; } +legend { padding: 0 5px; color: #415a75; font-size: 11px; font-weight: 700; } +.checkbox-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px 10px; } +.check-label { display: flex !important; align-items: center; gap: 6px; margin: 0 !important; font-size: 11px !important; font-weight: 500 !important; } +.check-label input { width: auto !important; margin: 0 !important; } +.filter-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 7px; margin-bottom: 7px; } +.filter-row input, .filter-row select { margin: 0 !important; padding: 7px !important; font-size: 11px; } +.link-button { padding: 0; border: 0; background: transparent; color: var(--blue); font-size: 11px; font-weight: 700; } +.field-help { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; font-weight: 400; } +.empty-state, .empty-results { display: flex; align-items: center; gap: 8px; padding: 22px; color: var(--muted); font-size: 12px; } +.empty-results { min-height: 130px; flex-direction: column; justify-content: center; border: 1px dashed #b9c8d7; background: #fff; } +.empty-results strong { color: var(--deep); }.empty-results span { font-size: 11px; } +.site-summary, .rejected-list { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 14px 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }.site-summary strong, .rejected-list strong { width: 100%; color: var(--deep); }.rejected-list { color: #a34732; background: #fff8f5; }.rejected-list span { display: flex; align-items: center; gap: 4px; }.result-status.danger { color: #a34732; background: #fff0eb; }.warning-accent strong { color: #a66d00 !important; } +.queue-panel { padding: 0 22px; }.queue-row { display: flex; justify-content: space-between; gap: 20px; padding: 18px 0; border-bottom: 1px solid var(--line); }.queue-row:last-child { border-bottom: 0; }.queue-row strong, .queue-row span, .queue-row small { display: block; }.queue-row span { margin-top: 5px; color: #a34732; font-size: 12px; }.queue-row small { margin-top: 5px; color: var(--muted); font-size: 10px; }.queue-actions { display: flex; align-items: center; gap: 7px; }.approve-button, .reject-button { display: flex; align-items: center; gap: 5px; padding: 8px 10px; border: 0; font-size: 11px; font-weight: 700; }.approve-button { color: var(--green); background: var(--green-bg); }.reject-button { color: #a34732; background: #fff0eb; } +@media (max-width: 620px) { .checkbox-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.filter-row { grid-template-columns: 1fr; }.queue-row { flex-direction: column; }.queue-actions { align-self: flex-start; } } diff --git a/frontend/app/layout.jsx b/frontend/app/layout.jsx new file mode 100644 index 0000000..e4dc67d --- /dev/null +++ b/frontend/app/layout.jsx @@ -0,0 +1,14 @@ +import './globals.css' + +export const metadata = { + title: 'Bifrost | Federated Analysis', + description: 'Disclosure-safe cross-TRE analysis workspace', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx new file mode 100644 index 0000000..6799c06 --- /dev/null +++ b/frontend/app/page.jsx @@ -0,0 +1,84 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { + Activity, BarChart3, Check, CircleHelp, Database, Download, FileClock, + FlaskConical, GitBranch, Info, LockKeyhole, Play, RefreshCw, ShieldCheck, + Table2, Waypoints, X, +} from 'lucide-react' + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8500' +const fallbackMetadata = { + 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' }, + ...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'], + sites: [{ tre_id: 'hunt', adapter: 'rest', region: 'NO' }, { tre_id: 'gefion', adapter: 'datashield', region: 'NO' }, { tre_id: 'brev', adapter: 'sql', region: 'NO' }], +} + +async function api(path, options) { + 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}`) + return body +} + +function Stat({ label, value, detail, accent = '' }) { + return
{label}{value}{detail}
+} + +function ResultView({ result }) { + const stats = result?.result || result || {} + const rows = Object.entries(stats.stats || {}).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) + const rejected = Object.entries(stats.rejected_per_site || {}).flatMap(([site, items]) => (items || []).map((item) => ({ site, item }))) + const sites = Object.entries(stats.n_per_site || {}) + const coverage = stats.coverage || `${stats.sites_reported?.length || 0}/${stats.sites_expected?.length || 0} sites` + const decision = result?.decision || result?.check?.decision || (result?.status === 'complete' ? 'RESULT AVAILABLE' : 'RUNNING') + return <>
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

{decision === 'REJECTED' || decision === 'FLAGGED' ? : } {decision}
{rows.length ?
{rows.map((row, index) => )}
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
}{sites.length ?
Per-site coverage{sites.map(([site, count]) => {site}: {count})}{(stats.sites_missing || []).map((site) => {site}: missing)}
: null}{rejected.length ?
Suppressed or rejected items{rejected.map((item, index) => {item.site}: {item.item})}
: null}
+} + +export default function Home() { + const [view, setView] = useState('analysis') + const [metadata, setMetadata] = useState(fallbackMetadata) + const [analysisType, setAnalysisType] = useState('allele_freq') + const [variables, setVariables] = useState(['snp_rs001']) + const [outcome, setOutcome] = useState('sbp') + const [rounds, setRounds] = useState(0) + const [projectId, setProjectId] = useState('ncfh-2026-demo') + const [minCell, setMinCell] = useState(5) + const [filterRows, setFilterRows] = useState([{ variable: '', op: 'eq', value: '' }]) + const [example, setExample] = useState('') + const [runId, setRunId] = useState('') + const [run, setRun] = useState(null) + const [pending, setPending] = useState([]) + const [audit, setAudit] = useState([]) + const [notice, setNotice] = useState('') + const [running, setRunning] = useState(false) + const [metadataError, setMetadataError] = useState(false) + + useEffect(() => { api('/metadata').then(setMetadata).catch(() => setMetadataError(true)) }, []) + 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 genotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type === 'genotype'), [metadata]) + const phenotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type !== 'genotype'), [metadata]) + const isRegression = analysisType === 'fed_linreg' + 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); setFilterRows(Object.entries(spec.filters || {}).map(([variable, filter]) => ({ variable, op: filter.op, value: filter.value })) || [{ variable: '', op: 'eq', value: '' }]) }).catch((error) => setNotice(error.message)) } + async function submitRun(event) { event.preventDefault(); setRunning(true); setRun(null); setNotice('Submitting analysis to Bifrost...'); const filters = Object.fromEntries(filterRows.filter((row) => row.variable && row.value !== '').map((row) => [row.variable, { op: row.op, value: row.value }])); try { const body = await api('/run', { method: 'POST', body: JSON.stringify({ analysis_type: analysisType, variables, filters, min_cell_size: Number(minCell), project_id: projectId, ...(isRegression ? { outcome, 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
BIFROSTFederated analysis gateway
Secure session
RW
++ {view === 'analysis' ? <>
Bifrost / 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 ?
: null}
Optional filters{filterRows.map((row, index) =>
updateFilter(index, 'value', event.target.value)} />
)}
Step 2

Connected TREs

{metadata.sites.length || '—'} online
{metadata.sites.length ? metadata.sites.map((site) =>
{site.tre_id || site.name}{site.adapter || 'adapter'} · {site.region || 'connected'}
Ready
) :
Site metadata is supplied by the API at run time.
}
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' ? <>
Bifrost / 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.
}
: <>
Bifrost / 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}`}
+} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8b05f1e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,502 @@ +{ + "name": "bifrost", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bifrost", + "version": "0.1.0", + "dependencies": { + "lucide-react": "latest", + "next": "latest", + "react": "latest", + "react-dom": "latest" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "optional": true + }, + "node_modules/@img/sharp-darwin-x64": { + "optional": true + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "optional": true + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "optional": true + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "optional": true + }, + "node_modules/@img/sharp-linux-arm": { + "optional": true + }, + "node_modules/@img/sharp-linux-arm64": { + "optional": true + }, + "node_modules/@img/sharp-linux-ppc64": { + "optional": true + }, + "node_modules/@img/sharp-linux-riscv64": { + "optional": true + }, + "node_modules/@img/sharp-linux-s390x": { + "optional": true + }, + "node_modules/@img/sharp-linux-x64": { + "optional": true + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "optional": true + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "optional": true + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "optional": true + }, + "node_modules/@img/sharp-win32-arm64": { + "optional": true + }, + "node_modules/@img/sharp-win32-ia32": { + "optional": true + }, + "node_modules/@img/sharp-win32-x64": { + "optional": true + }, + "node_modules/@next/env": { + "version": "16.3.5", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.5", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e84c3af --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "bifrost", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "next dev --webpack", + "build": "next build --webpack", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "latest", + "react-dom": "latest", + "lucide-react": "latest" + } +} From 9697480aebadfc3cd1fb46556fb92182eb29a3b5 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 12:27:07 +0100 Subject: [PATCH 04/11] fix(ui): preserve filters and disclosure boundaries --- frontend/app/page.jsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx index 6799c06..b56d873 100644 --- a/frontend/app/page.jsx +++ b/frontend/app/page.jsx @@ -32,24 +32,27 @@ function Stat({ label, value, detail, accent = '' }) { function ResultView({ result }) { const stats = result?.result || result || {} - const rows = Object.entries(stats.stats || {}).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) + const decision = result?.decision || result?.check?.decision || (result?.status === 'complete' ? 'RESULT AVAILABLE' : 'RUNNING') + const released = ['OK', 'RELEASED', 'APPROVED', 'RESULT AVAILABLE'].includes(decision) + const releasedStats = result?.released?.stats || result?.released_result?.stats || (released ? stats.stats : {}) + const rows = Object.entries(releasedStats).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) const rejected = Object.entries(stats.rejected_per_site || {}).flatMap(([site, items]) => (items || []).map((item) => ({ site, item }))) const sites = Object.entries(stats.n_per_site || {}) const coverage = stats.coverage || `${stats.sites_reported?.length || 0}/${stats.sites_expected?.length || 0} sites` - const decision = result?.decision || result?.check?.decision || (result?.status === 'complete' ? 'RESULT AVAILABLE' : 'RUNNING') - return <>
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

{decision === 'REJECTED' || decision === 'FLAGGED' ? : } {decision}
{rows.length ?
{rows.map((row, index) => )}
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
}{sites.length ?
Per-site coverage{sites.map(([site, count]) => {site}: {count})}{(stats.sites_missing || []).map((site) => {site}: missing)}
: null}{rejected.length ?
Suppressed or rejected items{rejected.map((item, index) => {item.site}: {item.item})}
: null}
+ const reasons = result?.reasons || result?.check?.reasons || [] + return <>
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

{!released ? : } {decision}
{!released ?
Output withheld pending release.{reasons.length ? reasons.map((reason) => {reason}) : The disclosure decision has not released statistics for this run.}
: rows.length ?
{rows.map((row, index) => )}
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
}{sites.length ?
Per-site coverage{sites.map(([site, count]) => {site}: {count})}{(stats.sites_missing || []).map((site) => {site}: missing)}
: null}{rejected.length ?
Suppressed or rejected items{rejected.map((item, index) => {item.site}: {item.item})}
: null}
} export default function Home() { const [view, setView] = useState('analysis') - const [metadata, setMetadata] = useState(fallbackMetadata) + const [metadata, setMetadata] = useState({ ...fallbackMetadata, sites: [] }) const [analysisType, setAnalysisType] = useState('allele_freq') const [variables, setVariables] = useState(['snp_rs001']) const [outcome, setOutcome] = useState('sbp') const [rounds, setRounds] = useState(0) const [projectId, setProjectId] = useState('ncfh-2026-demo') const [minCell, setMinCell] = useState(5) - const [filterRows, setFilterRows] = useState([{ variable: '', op: 'eq', value: '' }]) + const [filterRows, setFilterRows] = useState([{ variable: '', op: '==', value: '' }]) const [example, setExample] = useState('') const [runId, setRunId] = useState('') const [run, setRun] = useState(null) @@ -59,7 +62,7 @@ export default function Home() { const [running, setRunning] = useState(false) const [metadataError, setMetadataError] = useState(false) - useEffect(() => { api('/metadata').then(setMetadata).catch(() => setMetadataError(true)) }, []) + 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 @@ -74,11 +77,12 @@ export default function Home() { const isRegression = analysisType === 'fed_linreg' 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); setFilterRows(Object.entries(spec.filters || {}).map(([variable, filter]) => ({ variable, op: filter.op, value: filter.value })) || [{ variable: '', op: 'eq', value: '' }]) }).catch((error) => setNotice(error.message)) } - async function submitRun(event) { event.preventDefault(); setRunning(true); setRun(null); setNotice('Submitting analysis to Bifrost...'); const filters = Object.fromEntries(filterRows.filter((row) => row.variable && row.value !== '').map((row) => [row.variable, { op: row.op, value: row.value }])); try { const body = await api('/run', { method: 'POST', body: JSON.stringify({ analysis_type: analysisType, variables, filters, min_cell_size: Number(minCell), project_id: projectId, ...(isRegression ? { outcome, fedavg_rounds: Number(rounds) } : {}) }) }); setRunId(body.run_id || body.id); setNotice('Run submitted. Waiting for TRE responses...') } catch (error) { setNotice(error.message); setRunning(false) } } + 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 Bifrost...'); 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, 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
BIFROSTFederated analysis gateway
Secure session
RW
-+ {view === 'analysis' ? <>
Bifrost / 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 ?
: null}
Optional filters{filterRows.map((row, index) =>
updateFilter(index, 'value', event.target.value)} />
)}
Step 2

Connected TREs

{metadata.sites.length || '—'} online
{metadata.sites.length ? metadata.sites.map((site) =>
{site.tre_id || site.name}{site.adapter || 'adapter'} · {site.region || 'connected'}
Ready
) :
Site metadata is supplied by the API at run time.
}
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' ? <>
Bifrost / 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.
}
: <>
Bifrost / 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}`}
+ return
BIFROSTFederated analysis gateway
Secure session
RW
+ {view === 'analysis' ? <>
Bifrost / 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 ?
: 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' ? <>
Bifrost / 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.
}
: <>
Bifrost / 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}`}
} From a3eb52358f16cb545579cda2db41c01c98e48e26 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 12:27:33 +0100 Subject: [PATCH 05/11] fix(ui): style withheld and unavailable states --- frontend/app/globals.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9c2756f..f7fa503 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -32,4 +32,5 @@ legend { padding: 0 5px; color: #415a75; font-size: 11px; font-weight: 700; } .empty-results strong { color: var(--deep); }.empty-results span { font-size: 11px; } .site-summary, .rejected-list { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 14px 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }.site-summary strong, .rejected-list strong { width: 100%; color: var(--deep); }.rejected-list { color: #a34732; background: #fff8f5; }.rejected-list span { display: flex; align-items: center; gap: 4px; }.result-status.danger { color: #a34732; background: #fff0eb; }.warning-accent strong { color: #a66d00 !important; } .queue-panel { padding: 0 22px; }.queue-row { display: flex; justify-content: space-between; gap: 20px; padding: 18px 0; border-bottom: 1px solid var(--line); }.queue-row:last-child { border-bottom: 0; }.queue-row strong, .queue-row span, .queue-row small { display: block; }.queue-row span { margin-top: 5px; color: #a34732; font-size: 12px; }.queue-row small { margin-top: 5px; color: var(--muted); font-size: 10px; }.queue-actions { display: flex; align-items: center; gap: 7px; }.approve-button, .reject-button { display: flex; align-items: center; gap: 5px; padding: 8px 10px; border: 0; font-size: 11px; font-weight: 700; }.approve-button { color: var(--green); background: var(--green-bg); }.reject-button { color: #a34732; background: #fff0eb; } +.held-result { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px; color: #8d4a21; background: #fff8ed; border-bottom: 1px solid var(--line); font-size: 12px; }.held-result span { display: flex; align-items: center; gap: 6px; }.unknown-system { color: #a66d00; }.unknown-system i { background: #d28b16; }.unknown-state { color: #a66d00; } @media (max-width: 620px) { .checkbox-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.filter-row { grid-template-columns: 1fr; }.queue-row { flex-direction: column; }.queue-actions { align-self: flex-start; } } From 8c4ecf86889bc9689fdf3747dd00b799946c2310 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 12:27:41 +0100 Subject: [PATCH 06/11] chore(frontend): repair npm lockfile for clean installs --- frontend/package-lock.json | 574 ++++++++++++++++++++++++++++++++----- 1 file changed, 500 insertions(+), 74 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b05f1e..4edf052 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,16 @@ "react-dom": "latest" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "license": "MIT", @@ -22,80 +32,21 @@ "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "optional": true - }, - "node_modules/@img/sharp-darwin-x64": { - "optional": true - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "optional": true - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "optional": true - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "optional": true - }, - "node_modules/@img/sharp-linux-arm": { - "optional": true - }, - "node_modules/@img/sharp-linux-arm64": { - "optional": true - }, - "node_modules/@img/sharp-linux-ppc64": { - "optional": true - }, - "node_modules/@img/sharp-linux-riscv64": { - "optional": true - }, - "node_modules/@img/sharp-linux-s390x": { - "optional": true - }, - "node_modules/@img/sharp-linux-x64": { - "optional": true - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "optional": true - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "optional": true - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "optional": true - }, - "node_modules/@img/sharp-win32-arm64": { - "optional": true - }, - "node_modules/@img/sharp-win32-ia32": { - "optional": true - }, - "node_modules/@img/sharp-win32-x64": { - "optional": true + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, "node_modules/@next/env": { "version": "16.3.5", @@ -466,6 +417,481 @@ } } }, + "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/sharp/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", From b5e4ed7d40946b181217376ac8f6397c82992619 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Thu, 17 Sep 2026 12:27:52 +0100 Subject: [PATCH 07/11] docs: resolve AGENTS merge conflict --- AGENTS.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e894ea..a3b1c5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ Bifrost — run one analysis across several Trusted Research Environments (TREs) Python 3.11–3.12, NVIDIA FLARE, FastAPI, DuckDB, Docker Compose. Dependency version constraints are declared in [pyproject.toml](pyproject.toml); ask before adding one. -## Status (2026-09-17, after M5) +A real (non-simulator) FLARE federation runs two verified ways: `scripts/local_federation.sh` with separate processes over mTLS, and Docker via `scripts/provision.sh && scripts/up.sh --flare`; both support the example specs, overseer flow, and partial-site coverage. The Next.js UI is implemented in `frontend/`; the Python API service exposing its documented contract remains separate. Not yet run: clients on remote hosts (Gefion / NextCloud). Implemented: + | Area | Where | | --- | --- | | Site definitions | `sites.yaml` — three TREs (`hunt`/rest, `gefion`/datashield, `brev`/sql); `scripts/sites.py` is the only loader (`SITES_PATH` env overrides it for simulations) | @@ -27,11 +28,7 @@ Implemented: | Ops scripts | `scripts/local_federation.sh` (real FLARE, no Docker), `scripts/provision.sh`, `scripts/onboard_tre.sh`, `scripts/start_server.sh`, `scripts/start_client.sh`, `scripts/run_job.py` (`--mode simulator|prod`, `--fedavg`), `scripts/run_local.py`, `scripts/verify.py`, `scripts/scale_sim.py` → `docs/scaling.png` | | Tests | `tests/` — 31 fast + 3 FLARE-simulator (`-m slow`) | -<<<<<<< HEAD -A real (non-simulator) FLARE federation — provisioned server + clients as separate processes over mTLS — runs on one machine with `scripts/local_federation.sh`; all example specs, the overseer flow and a dead-client run were verified on it. The Next.js UI is implemented in `frontend/`; the Python API service exposing its documented contract remains separate. Not yet run anywhere: the Docker path (`scripts/up.sh`, images) — no Docker on the development machine. -======= -A real (non-simulator) FLARE federation runs two ways and both are verified: `scripts/local_federation.sh` (separate processes on one machine) and Docker (`scripts/provision.sh && scripts/up.sh --flare`, then `docker compose exec flare-server python scripts/run_job.py --mode prod ...`): images build, TREs are internet-isolated, clients register over mTLS, all example specs pass, a stopped `flare-brev` container yields `2/3 sites`. Not implemented: UI. Not yet run: clients on remote hosts (Gefion / NextCloud). ->>>>>>> b5a7dac15e4aeb3559fe338b009411a15aaacc01 +A real (non-simulator) FLARE federation runs two verified ways: `scripts/local_federation.sh` with separate processes over mTLS, and Docker via `scripts/provision.sh && scripts/up.sh --flare`; both support the example specs, overseer flow, and partial-site coverage. The Next.js UI is implemented in `frontend/`; the Python API service exposing its documented contract remains separate. Not yet run: clients on remote hosts (Gefion / NextCloud). Known discrepancies — do not treat these as existing: From 20e106858b3e09f05a25f5b9daffe7d06c460a47 Mon Sep 17 00:00:00 2001 From: Vitalii Babenko Date: Thu, 17 Sep 2026 14:54:01 +0200 Subject: [PATCH 08/11] fix(ui): withhold held counts and handle empty results --- frontend/app/page.jsx | 18 +---- frontend/app/result-view.jsx | 59 ++++++++++++++++ frontend/jsx-loader.mjs | 18 +++++ frontend/package.json | 3 +- frontend/register-jsx-loader.mjs | 3 + frontend/test/result-view.test.js | 111 ++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 frontend/app/result-view.jsx create mode 100644 frontend/jsx-loader.mjs create mode 100644 frontend/register-jsx-loader.mjs create mode 100644 frontend/test/result-view.test.js diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx index b56d873..bb7681e 100644 --- a/frontend/app/page.jsx +++ b/frontend/app/page.jsx @@ -6,6 +6,7 @@ import { FlaskConical, GitBranch, Info, LockKeyhole, Play, RefreshCw, ShieldCheck, Table2, Waypoints, X, } from 'lucide-react' +import { ResultView } from './result-view' const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8500' const fallbackMetadata = { @@ -26,23 +27,6 @@ async function api(path, options) { return body } -function Stat({ label, value, detail, accent = '' }) { - return
{label}{value}{detail}
-} - -function ResultView({ result }) { - const stats = result?.result || result || {} - const decision = result?.decision || result?.check?.decision || (result?.status === 'complete' ? 'RESULT AVAILABLE' : 'RUNNING') - const released = ['OK', 'RELEASED', 'APPROVED', 'RESULT AVAILABLE'].includes(decision) - const releasedStats = result?.released?.stats || result?.released_result?.stats || (released ? stats.stats : {}) - const rows = Object.entries(releasedStats).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) - const rejected = Object.entries(stats.rejected_per_site || {}).flatMap(([site, items]) => (items || []).map((item) => ({ site, item }))) - const sites = Object.entries(stats.n_per_site || {}) - const coverage = stats.coverage || `${stats.sites_reported?.length || 0}/${stats.sites_expected?.length || 0} sites` - const reasons = result?.reasons || result?.check?.reasons || [] - return <>
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

{!released ? : } {decision}
{!released ?
Output withheld pending release.{reasons.length ? reasons.map((reason) => {reason}) : The disclosure decision has not released statistics for this run.}
: rows.length ?
{rows.map((row, index) => )}
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
}{sites.length ?
Per-site coverage{sites.map(([site, count]) => {site}: {count})}{(stats.sites_missing || []).map((site) => {site}: missing)}
: null}{rejected.length ?
Suppressed or rejected items{rejected.map((item, index) => {item.site}: {item.item})}
: null}
-} - export default function Home() { const [view, setView] = useState('analysis') const [metadata, setMetadata] = useState({ ...fallbackMetadata, sites: [] }) diff --git a/frontend/app/result-view.jsx b/frontend/app/result-view.jsx new file mode 100644 index 0000000..0a236e9 --- /dev/null +++ b/frontend/app/result-view.jsx @@ -0,0 +1,59 @@ +import { Check, Info, X } from 'lucide-react' + +function Stat({ label, value, detail, accent = '' }) { + return
{label}{value}{detail}
+} + +function heldReason(reason) { + if (reason.startsWith("min_sites:")) return "Insufficient site coverage for release." + if (reason.startsWith("site_suppression:")) return "A site-level result requires disclosure review." + if (reason.startsWith("k_anon:")) return "A result did not meet the minimum cell-size policy." + if (reason.startsWith("dominance:")) return "A result did not meet the dominance policy." + if (reason.startsWith("differencing:")) return "A result did not meet the differencing policy." + return "Disclosure review required." +} + +export function ResultView({ result }) { + const rawStats = result?.result || result || {} + const decision = result?.decision || result?.check?.decision || (result?.status === 'complete' ? 'RESULT AVAILABLE' : 'RUNNING') + const released = ['OK', 'RELEASED', 'APPROVED', 'RESULT AVAILABLE'].includes(decision) + const releasedProjection = result?.released || result?.released_result || {} + const stats = released ? { ...rawStats, ...releasedProjection } : rawStats + const releasedStats = stats.stats || {} + const rows = Object.entries(releasedStats).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) + const rejected = Object.entries(rawStats.rejected_per_site || {}).flatMap(([site, items]) => (items || []).map((item) => ({ site, item }))) + const sites = Object.entries(stats.n_per_site || {}) + const coverage = stats.coverage || `${stats.sites_reported?.length || 0}/${stats.sites_expected?.length || 0} sites` + const reasons = result?.reasons || result?.check?.reasons || [] + const heldReasons = [...new Set(reasons.map(heldReason))] + + return <> +
+
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

+ {!released ? : } {decision} +
+
+ {released ?
+ + + +
: null} + {!released ?
+ Output withheld pending release. + {heldReasons.length ? heldReasons.map((reason) => {reason}) : The disclosure decision has not released statistics for this run.} +
: rows.length ?
+ + {rows.map((row, index) => )} +
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
} + {released && sites.length ?
+ Per-site coverage + {sites.map(([site, count]) => {site}: {count})} + {(stats.sites_missing || []).map((site) => {site}: missing)} +
: null} + {released && rejected.length ?
+ Suppressed or rejected items + {rejected.map((item, index) => {item.site}: {item.item})} +
: null} +
+ +} diff --git a/frontend/jsx-loader.mjs b/frontend/jsx-loader.mjs new file mode 100644 index 0000000..edc5caa --- /dev/null +++ b/frontend/jsx-loader.mjs @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import { loadBindings, transform } from 'next/dist/build/swc/index.js' + +export async function load(url, context, nextLoad) { + if (!url.endsWith('.jsx')) return nextLoad(url, context) + + const source = await readFile(new URL(url), 'utf8') + await loadBindings() + const transformed = await transform(source, { + filename: url, + jsc: { + parser: { syntax: 'ecmascript', jsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + module: { type: 'es6' }, + }) + return { format: 'module', source: transformed.code, shortCircuit: true } +} diff --git a/frontend/package.json b/frontend/package.json index e84c3af..249d9b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", - "start": "next start" + "start": "next start", + "test": "node --import ./register-jsx-loader.mjs --test" }, "dependencies": { "next": "latest", diff --git a/frontend/register-jsx-loader.mjs b/frontend/register-jsx-loader.mjs new file mode 100644 index 0000000..391b2b4 --- /dev/null +++ b/frontend/register-jsx-loader.mjs @@ -0,0 +1,3 @@ +import { register } from 'node:module' + +register('./jsx-loader.mjs', import.meta.url) diff --git a/frontend/test/result-view.test.js b/frontend/test/result-view.test.js new file mode 100644 index 0000000..da05271 --- /dev/null +++ b/frontend/test/result-view.test.js @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement } from 'react' +import { ResultView } from '../app/result-view.jsx' + +function render(result) { + return renderToStaticMarkup(createElement(ResultView, { result })) +} + +for (const [decision, projectionKey] of [['FLAGGED', 'released'], ['REJECTED', 'released_result']]) { + test(`holds total and per-site counts with ${decision.toLowerCase()} output`, () => { + const html = render({ + decision, + reasons: ['Small cell requires review'], + result: { + coverage: '3/3 sites', + sites_missing: [], + n: 42, + n_per_site: { hunt: 21, gefion: 21 }, + rejected_per_site: { hunt: ['age'] }, + stats: { age: { mean: 48 } }, + }, + [projectionKey]: { + n: 999, + n_per_site: { stale: 999 }, + stats: { age: { mean: 999 } }, + }, + }) + + assert.match(html, /Output withheld pending release/) + assert.doesNotMatch(html, /Records analysed/) + assert.doesNotMatch(html, /hunt: 21/) + assert.doesNotMatch(html, /stale: 999/) + assert.doesNotMatch(html, /mean/) + }) +} + +test('renders a completed response without statistics', () => { + const html = render({ status: 'complete' }) + + assert.match(html, /The API returned no released statistics for this run/) +}) + +test('uses released result counts and statistics over the raw result', () => { + const html = render({ + decision: 'APPROVED', + result: { + coverage: '9/9 sites', + sites_missing: [], + n: 99, + n_per_site: { raw: 99 }, + stats: { age: { mean: 99 } }, + }, + released_result: { + coverage: '2/2 sites', + sites_missing: [], + n: 24, + n_per_site: { hunt: 12, gefion: 12 }, + stats: { age: { mean: 48 } }, + }, + }) + + assert.match(html, /Records analysed/) + assert.match(html, /24/) + assert.match(html, /hunt: 12/) + assert.match(html, /48/) + assert.doesNotMatch(html, /raw: 99/) + assert.doesNotMatch(html, />99 { + const html = render({ + decision: 'FLAGGED', + reasons: [ + 'min_sites:1<2', + 'site_suppression:hunt:1', + 'k_anon:age.count=2<5', + 'dominance:age.count:hunt=0.95>0.9', + 'differencing:prev=secret:|11-12|<5', + 'unknown:42', + 'unknown:42', + ], + result: { + n: 2, + n_per_site: { hunt: 2 }, + rejected_per_site: { hunt: ['age.count:count=2<5'] }, + stats: { age: { count: 2 } }, + }, + }) + + for (const explanation of [ + 'Insufficient site coverage for release.', + 'A site-level result requires disclosure review.', + 'A result did not meet the minimum cell-size policy.', + 'A result did not meet the dominance policy.', + 'A result did not meet the differencing policy.', + 'Disclosure review required.', + ]) assert.match(html, new RegExp(explanation.replace(/[.]/g, '\\.'))) + + assert.doesNotMatch(html, /min_sites:1<2|count=2|secret|Suppressed or rejected items|hunt: 2/) +}) + +test('shows rejected items after release', () => { + const html = render({ + decision: 'APPROVED', + result: { rejected_per_site: { hunt: ['age.count:count=2<5'] }, stats: {} }, + }) + + assert.match(html, /Suppressed or rejected items/) + assert.match(html, /age.count:count=2<5/) +}) From bf4b3fca295d685fb306da89e1a2aef1f26e56c4 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Fri, 18 Sep 2026 11:23:32 +0100 Subject: [PATCH 09/11] feat: merge remote updates --- data/ground_truth.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/data/ground_truth.json b/data/ground_truth.json index 9e40d92..276481b 100644 --- a/data/ground_truth.json +++ b/data/ground_truth.json @@ -134,7 +134,7 @@ "snp_rs002": 0.2631053824858282, "snp_rs003": 0.2557050848967969, "snp_rs004": 0.17184630055264521, - "snp_rs005": 0.1433867357251611, + "snp_rs005": 0.14338673572516114, "snp_rs006": 0.28728302299079206, "snp_rs007": 0.184047691898643, "snp_rs008": 0.05622063648587075, @@ -143,17 +143,17 @@ "snp_rs011": 0.061729133072990064, "snp_rs012": 0.1578032172887608, "snp_rs013": 0.09786832426523517, - "snp_rs014": 0.4472563014604798, + "snp_rs014": 0.44725630146047973, "snp_rs015": 0.20450649142266342, "snp_rs016": 0.12313159163270347, - "snp_rs017": 0.3811202897045791, + "snp_rs017": 0.38112028970457906, "snp_rs018": 0.09442874867196407, - "snp_rs019": 0.18786711849245732, + "snp_rs019": 0.18786711849245735, "snp_rs020": 0.34298493736871516 }, "maf_site_drift": { "hunt": 0.7260203065811822, - "gefion": 1.2888214730130823, + "gefion": 1.288821473013082, "brev": 0.7682591778396055 }, "site_allele_freq": { @@ -236,14 +236,14 @@ "snp_rs013" ], "coef": { - "intercept": 89.27288604255267, - "age": 0.505543652143548, - "sex": 4.203368841196924, - "bmi": 0.8062409982940072, - "ldl": 1.2189188961362323, - "snp_rs001": 2.3934157478351277, - "snp_rs007": -1.2816779284343343, - "snp_rs013": 0.9002997078400994 + "intercept": 89.2728860425527, + "age": 0.5055436521435493, + "sex": 4.203368841196912, + "bmi": 0.8062409982940054, + "ldl": 1.2189188961362305, + "snp_rs001": 2.3934157478351294, + "snp_rs007": -1.2816779284343325, + "snp_rs013": 0.9002997078401026 } }, "beta_true": { From 89c4c565d4d1df642a1b27f60d53b412208badaf Mon Sep 17 00:00:00 2001 From: emiridbest Date: Fri, 18 Sep 2026 12:20:50 +0100 Subject: [PATCH 10/11] feat: add support for logistic regression and validate all API integrations --- frontend/app/page.jsx | 497 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 469 insertions(+), 28 deletions(-) diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx index 3f2c678..611f933 100644 --- a/frontend/app/page.jsx +++ b/frontend/app/page.jsx @@ -7,7 +7,6 @@ import { Table2, Waypoints, X, } from 'lucide-react' - const API_BASE = '/api' const staticMetadata = { projects: [{ id: 'ncfh-2026-demo' }, { id: 'hackathon-test' }], @@ -16,21 +15,34 @@ const staticMetadata = { { name: 'bmi', type: 'continuous' }, { name: 'sbp', type: 'continuous' }, ...Array.from({ length: 20 }, (_, index) => ({ name: `snp_rs${String(index + 1).padStart(3, '0')}`, type: 'genotype' })), ], - // No /examples endpoint on this server, so there is nothing to list here. examples: [], sites: [], - analysis_types: ['allele_freq', 'fed_stats', 'fed_linreg'], + analysis_types: [ + 'allele_freq', + 'fed_stats', + 'fed_linreg', + 'logistic_regression', + ], } 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.error || body.message || `API returned ${response.status}`) return body } function Stat({ label, value, detail, accent = '' }) { - return
{label}{value}{detail}
+ return ( +
+ {label} + {value} + {detail} +
+ ) } function ResultView({ result }) { @@ -38,17 +50,79 @@ function ResultView({ result }) { const decision = result?.decision || result?.check?.decision || (['complete', 'completed'].includes(result?.status) ? 'RESULT AVAILABLE' : 'RUNNING') const released = ['OK', 'RELEASED', 'APPROVED', 'RESULT AVAILABLE'].includes(decision) const releasedStats = result?.released?.stats || result?.released_result?.stats || (released ? stats.stats : {}) - const rows = Object.entries(releasedStats).flatMap(([variable, value]) => Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output }))) + const rows = Object.entries(releasedStats || {}).flatMap(([variable, value]) => + Object.entries(value || {}).map(([metric, output]) => ({ variable, metric, output })) + ) const rejected = Object.entries(stats.rejected_per_site || {}).flatMap(([site, items]) => (items || []).map((item) => ({ site, item }))) const sites = Object.entries(stats.n_per_site || {}) const coverage = stats.coverage || `${stats.sites_reported?.length || 0}/${stats.sites_expected?.length || 0} sites` const reasons = result?.reasons || result?.check?.reasons || [] - return <>
Step 3

Federated result

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

{!released ? : } {decision}
{!released ?
Output withheld pending release.{reasons.length ? reasons.map((reason) => {reason}) : The disclosure decision has not released statistics for this run.}
: rows.length ?
{rows.map((row, index) => )}
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
:
The API returned no released statistics for this run.
}{sites.length ?
Per-site coverage{sites.map(([site, count]) => {site}: {count})}{(stats.sites_missing || []).map((site) => {site}: missing)}
: null}{rejected.length ?
Suppressed or rejected items{rejected.map((item, index) => {item.site}: {item.item})}
: null}
+ + return ( + <> +
+
+ Step 3 +

Federated result

+

Spec hash: {result?.spec_hash || result?.run?.spec_hash || stats.spec_hash || 'pending'}

+
+ + {!released ? : } {decision} + +
+
+
+ + + +
+ {!released ? ( +
+ Output withheld pending release. + {reasons.length ? reasons.map((reason) => {reason}) : The disclosure decision has not released statistics for this run.} +
+ ) : rows.length ? ( +
+ + + + + + {rows.map((row, index) => ( + + + + + + ))} + +
VariableStatisticReleased value
{row.variable}{row.metric}{typeof row.output === 'object' ? JSON.stringify(row.output) : String(row.output)}
+
+ ) : ( +
+ The API returned no released statistics for this run. +
+ )} + {sites.length ? ( +
+ Per-site coverage + {sites.map(([site, count]) => {site}: {count})} + {(stats.sites_missing || []).map((site) => {site}: missing)} +
+ ) : null} + {rejected.length ? ( +
+ Suppressed or rejected items + {rejected.map((item, index) => {item.site}: {item.item})} +
+ ) : null} +
+ + ) } export default function Home() { const [view, setView] = useState('analysis') - // No /metadata route exists, so this is static rather than fetched. const [metadata, setMetadata] = useState(staticMetadata) const [analysisType, setAnalysisType] = useState('allele_freq') const [variables, setVariables] = useState(['snp_rs001']) @@ -66,35 +140,67 @@ export default function Home() { const [running, setRunning] = useState(false) const [metadataError, setMetadataError] = useState(false) + const binaryVariables = useMemo( + () => metadata.variables.filter((variable) => variable.type === 'binary'), + [metadata] + ) + + useEffect(() => { + if ( + analysisType === 'logistic_regression' && + !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 ?? null })) }) - setProjectId(body.projects?.[0]?.id || 'ncfh-2026-demo') - setMetadataError(false) - }).catch(() => setMetadataError(true)) + api('/metadata') + .then((body) => { + setMetadata({ + ...staticMetadata, + ...body, + sites: (body.sites || []).map((site) => ({ ...site, online: site.online ?? null })), + }) + setProjectId(body.projects?.[0]?.id || 'ncfh-2026-demo') + setMetadataError(false) + }) + .catch(() => setMetadataError(true)) }, []) + + // Safely recursive timeout polling loop useEffect(() => { - if (!runId) return undefined - let timer + 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)) { + if (['queued', 'running', 'pending'].includes(next.status)) { + timeoutId = setTimeout(poll, 1200) + } else { setRunning(false) - clearInterval(timer) } } catch (error) { + if (!isActive) return setNotice(error.message) setRunning(false) - clearInterval(timer) } } - timer = setInterval(poll, 1200) + poll() - return () => clearInterval(timer) + + 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)) @@ -103,9 +209,15 @@ export default function Home() { const genotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type === 'genotype'), [metadata]) const phenotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type !== 'genotype'), [metadata]) const isRegression = analysisType === 'fed_linreg' || analysisType === 'logistic_regression' - const isFedAvg = analysisType === 'fed_linreg' - 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 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 @@ -118,7 +230,19 @@ export default function Home() { setFilterRows(Object.entries(spec.filters || {}).flatMap(([variable, conditions]) => Object.entries(conditions).map(([op, filterValue]) => ({ variable, op, value: String(filterValue) }))) || [{ 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 } } }, {}) } + + 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() @@ -142,7 +266,6 @@ export default function Home() { setNotice('Run submitted. Waiting for TRE responses...') } catch (error) { setNotice(error.message) - } finally { setRunning(false) } } @@ -157,7 +280,325 @@ export default function Home() { } } - return
BIFROSTFederated analysis gateway
Secure session
RW
- {view === 'analysis' ? <>
Bifrost / 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 ?
: 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' ? <>
Bifrost / 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.
}
: <>
Bifrost / 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}`}
+ 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 ? ( +
+ + +
+ ) : 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}`} +
+
+
+
+ ) } \ No newline at end of file From 549d3b0dd6ef3d9db342ff676a7f618f75fe1031 Mon Sep 17 00:00:00 2001 From: emiridbest Date: Fri, 18 Sep 2026 13:15:12 +0100 Subject: [PATCH 11/11] frontend: use /api proxy, support fed_logreg, and unify result rendering --- frontend/app/page.jsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/app/page.jsx b/frontend/app/page.jsx index c4a6b55..af297f0 100644 --- a/frontend/app/page.jsx +++ b/frontend/app/page.jsx @@ -6,6 +6,7 @@ import { FlaskConical, GitBranch, Info, LockKeyhole, Play, RefreshCw, ShieldCheck, Table2, Waypoints, X, } from 'lucide-react' +import { ResultView } from './result-view' const API_BASE = '/api' const staticMetadata = { @@ -28,7 +29,7 @@ const staticMetadata = { 'allele_freq', 'fed_stats', 'fed_linreg', - 'logistic_regression', + 'fed_logreg', ], } @@ -42,7 +43,7 @@ async function api(path, options) { return body } -function Stat({ label, value, detail, accent = '' }) { +function LegacyStat({ label, value, detail, accent = '' }) { return (
{label} @@ -52,7 +53,9 @@ function Stat({ label, value, detail, accent = '' }) { ) } -function ResultView({ result }) { +const Stat = LegacyStat + +function LegacyResultView({ result }) { const stats = result?.result || result || {} const decision = result?.decision || result?.check?.decision || (['complete', 'completed'].includes(result?.status) ? 'RESULT AVAILABLE' : 'RUNNING') const released = ['OK', 'RELEASED', 'APPROVED', 'RESULT AVAILABLE'].includes(decision) @@ -155,12 +158,12 @@ export default function Home() { const phenotypeVariables = useMemo(() => metadata.variables.filter((variable) => variable.type !== 'genotype'), [metadata]) const isLinear = analysisType === 'fed_linreg' - const isLogistic = analysisType === 'logistic_regression' + const isLogistic = analysisType === 'fed_logreg' const isRegression = isLinear || isLogistic useEffect(() => { if ( - analysisType === 'logistic_regression' && + analysisType === 'fed_logreg' && !binaryVariables.some((variable) => variable.name === outcome) ) { setOutcome(binaryVariables[0]?.name || '') @@ -173,7 +176,7 @@ export default function Home() { setMetadata({ ...staticMetadata, ...body, - sites: (body.sites || []).map((site) => ({ ...site, online: site.online ?? null })), + sites: (body.sites || []).map((site) => ({ ...site, online: site.online ?? site.status === 'ok' })), }) setProjectId(body.projects?.[0]?.id || 'ncfh-2026-demo') setMetadataError(false) @@ -374,7 +377,7 @@ export default function Home() {
) -} \ No newline at end of file +}