mekosotto commited on
Commit
ef4cf4a
·
1 Parent(s): 60b8d69

feat(frontend): Streamlit dashboard with 3 modality tabs

Browse files
.streamlit/config.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuroBridge Enterprise — Trust & Authority brand theme
2
+ # Locked to the design-system tokens defined in Day-4 Task 10.
3
+
4
+ [theme]
5
+ primaryColor = "#0369A1"
6
+ backgroundColor = "#F8FAFC"
7
+ secondaryBackgroundColor = "#FFFFFF"
8
+ textColor = "#020617"
9
+ font = "sans serif"
10
+
11
+ [browser]
12
+ gatherUsageStats = false
13
+
14
+ [server]
15
+ headless = true
requirements.txt CHANGED
@@ -32,3 +32,6 @@ mlflow==2.16.0
32
  pytest==8.3.3
33
  pytest-cov==5.0.0
34
  httpx==0.27.2 # FastAPI test client
 
 
 
 
32
  pytest==8.3.3
33
  pytest-cov==5.0.0
34
  httpx==0.27.2 # FastAPI test client
35
+
36
+ # --- Frontend (B2B dashboard) ---
37
+ streamlit==1.39.0
src/frontend/__init__.py ADDED
File without changes
src/frontend/app.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NeuroBridge Enterprise — Streamlit B2B dashboard.
2
+
3
+ Three tabs (Molecule / Signal / Image), each fires a POST request against the
4
+ sibling FastAPI service and renders a result card with row counts, runtime,
5
+ and a deep link to the corresponding MLflow run.
6
+
7
+ Design: Trust & Authority — navy + sky CTA + cool-white background, Plus
8
+ Jakarta Sans, generous whitespace. Avoids emoji icons, AI gradients, and
9
+ playful flourishes (per design-system guidance for clinical-ML B2B).
10
+
11
+ Launch: `streamlit run src/frontend/app.py`
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import html as _html
16
+ import os
17
+
18
+ import httpx
19
+ import streamlit as st
20
+
21
+
22
+ _API_URL = os.environ.get("NEUROBRIDGE_API_URL", "http://localhost:8000")
23
+ _MLFLOW_URL = os.environ.get(
24
+ "NEUROBRIDGE_MLFLOW_URL",
25
+ os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000"),
26
+ )
27
+ _MLFLOW_DISABLED = os.environ.get("NEUROBRIDGE_DISABLE_MLFLOW") == "1"
28
+
29
+
30
+ # Trust & Authority custom CSS — overrides Streamlit defaults to lock the
31
+ # design-system tokens. Loaded once at app start via st.markdown.
32
+ _CUSTOM_CSS = """
33
+ <style>
34
+ @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
35
+
36
+ html, body, [class*="css"], .stApp, .stMarkdown, .stTabs, .stButton, .stTextInput {
37
+ font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
38
+ }
39
+
40
+ .stApp {
41
+ background-color: #F8FAFC;
42
+ }
43
+
44
+ /* Brand header band */
45
+ .brand-header {
46
+ background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
47
+ color: #F8FAFC;
48
+ padding: 1.75rem 2rem;
49
+ border-radius: 12px;
50
+ margin-bottom: 1.5rem;
51
+ border: 1px solid #1E293B;
52
+ }
53
+ .brand-header h1 {
54
+ font-size: 1.75rem;
55
+ font-weight: 700;
56
+ letter-spacing: -0.02em;
57
+ margin: 0;
58
+ color: #FFFFFF;
59
+ }
60
+ .brand-header p {
61
+ color: #94A3B8;
62
+ margin: 0.25rem 0 0 0;
63
+ font-size: 0.95rem;
64
+ font-weight: 400;
65
+ }
66
+
67
+ /* Status pills */
68
+ .status-pill {
69
+ display: inline-block;
70
+ padding: 0.25rem 0.75rem;
71
+ border-radius: 999px;
72
+ font-size: 0.78rem;
73
+ font-weight: 600;
74
+ letter-spacing: 0.02em;
75
+ margin-right: 0.5rem;
76
+ }
77
+ .status-ok { background: #DCFCE7; color: #166534; }
78
+ .status-warn { background: #FEF3C7; color: #92400E; }
79
+ .status-down { background: #FEE2E2; color: #991B1B; }
80
+
81
+ /* Cards / metric containers */
82
+ [data-testid="stMetric"] {
83
+ background: #FFFFFF;
84
+ border: 1px solid #E2E8F0;
85
+ border-radius: 10px;
86
+ padding: 1.1rem 1.25rem;
87
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
88
+ }
89
+ [data-testid="stMetricLabel"] {
90
+ color: #64748B;
91
+ font-size: 0.78rem;
92
+ font-weight: 600;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.04em;
95
+ }
96
+ [data-testid="stMetricValue"] {
97
+ color: #0F172A;
98
+ font-weight: 700;
99
+ font-size: 1.85rem;
100
+ }
101
+
102
+ /* Primary action button */
103
+ .stButton > button[kind="primary"] {
104
+ background: #0369A1;
105
+ color: #FFFFFF;
106
+ border: 0;
107
+ border-radius: 8px;
108
+ font-weight: 600;
109
+ padding: 0.55rem 1.2rem;
110
+ transition: background 180ms ease, transform 120ms ease;
111
+ }
112
+ .stButton > button[kind="primary"]:hover {
113
+ background: #075985;
114
+ transform: translateY(-1px);
115
+ }
116
+ .stButton > button[kind="primary"]:focus {
117
+ outline: 3px solid rgba(3, 105, 161, 0.35);
118
+ outline-offset: 2px;
119
+ }
120
+
121
+ /* Tab strip */
122
+ .stTabs [data-baseweb="tab-list"] {
123
+ gap: 0.25rem;
124
+ border-bottom: 1px solid #E2E8F0;
125
+ }
126
+ .stTabs [data-baseweb="tab"] {
127
+ color: #64748B;
128
+ font-weight: 600;
129
+ padding: 0.65rem 1.25rem;
130
+ border-bottom: 2px solid transparent;
131
+ transition: color 150ms ease, border-color 150ms ease;
132
+ }
133
+ .stTabs [aria-selected="true"] {
134
+ color: #0F172A !important;
135
+ border-bottom-color: #0369A1 !important;
136
+ }
137
+
138
+ /* Section headers inside tabs */
139
+ .section-eyebrow {
140
+ font-size: 0.72rem;
141
+ font-weight: 700;
142
+ color: #0369A1;
143
+ letter-spacing: 0.08em;
144
+ text-transform: uppercase;
145
+ margin: 0;
146
+ }
147
+ .section-title {
148
+ font-size: 1.35rem;
149
+ font-weight: 700;
150
+ color: #0F172A;
151
+ margin: 0.15rem 0 0.5rem 0;
152
+ letter-spacing: -0.01em;
153
+ }
154
+ .section-desc {
155
+ color: #475569;
156
+ font-size: 0.95rem;
157
+ margin: 0 0 1.5rem 0;
158
+ line-height: 1.6;
159
+ }
160
+
161
+ /* Result card link styling */
162
+ .mlflow-link a {
163
+ color: #0369A1;
164
+ text-decoration: none;
165
+ font-weight: 600;
166
+ border-bottom: 1px solid rgba(3, 105, 161, 0.25);
167
+ transition: border-color 150ms ease;
168
+ }
169
+ .mlflow-link a:hover {
170
+ border-bottom-color: #0369A1;
171
+ }
172
+
173
+ /* Sidebar */
174
+ section[data-testid="stSidebar"] {
175
+ background: #FFFFFF;
176
+ border-right: 1px solid #E2E8F0;
177
+ }
178
+ section[data-testid="stSidebar"] h3 {
179
+ font-size: 0.78rem;
180
+ font-weight: 700;
181
+ color: #64748B;
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.06em;
184
+ }
185
+ </style>
186
+ """
187
+
188
+
189
+ def _check_api_health() -> tuple[bool, str]:
190
+ """Ping FastAPI /health endpoint; return (ok, status_text)."""
191
+ try:
192
+ resp = httpx.get(f"{_API_URL}/health", timeout=2.0)
193
+ if resp.status_code == 200:
194
+ return True, "ok"
195
+ return False, f"http {resp.status_code}"
196
+ except httpx.RequestError as e:
197
+ return False, str(type(e).__name__)
198
+
199
+
200
+ def _post(endpoint: str, payload: dict) -> dict:
201
+ """POST to the FastAPI surface; let httpx raise on non-2xx."""
202
+ resp = httpx.post(f"{_API_URL}{endpoint}", json=payload, timeout=120.0)
203
+ resp.raise_for_status()
204
+ return resp.json()
205
+
206
+
207
+ def _render_brand_header() -> None:
208
+ st.markdown(
209
+ """
210
+ <div class="brand-header">
211
+ <h1>NeuroBridge Enterprise</h1>
212
+ <p>Three-modality clinical ML — Data Drift, Missing Modalities, Artifacts</p>
213
+ </div>
214
+ """,
215
+ unsafe_allow_html=True,
216
+ )
217
+
218
+
219
+ def _render_section(eyebrow: str, title: str, desc: str) -> None:
220
+ st.markdown(
221
+ f"""
222
+ <p class="section-eyebrow">{eyebrow}</p>
223
+ <h2 class="section-title">{title}</h2>
224
+ <p class="section-desc">{desc}</p>
225
+ """,
226
+ unsafe_allow_html=True,
227
+ )
228
+
229
+
230
+ def _render_result(body: dict) -> None:
231
+ """Render a 3-metric result card + MLflow deep link."""
232
+ cols = st.columns(3)
233
+ cols[0].metric("Rows", f"{body['rows']:,}")
234
+ cols[1].metric("Columns", f"{body['columns']:,}")
235
+ cols[2].metric("Runtime", f"{body['duration_sec']:.2f} s")
236
+
237
+ safe_output_path = _html.escape(str(body["output_path"]))
238
+ st.markdown(
239
+ f"<p style='color:#475569;margin:1rem 0 0.5rem 0;font-size:0.9rem;'>"
240
+ f"Output written to <code style='background:#E8ECF1;padding:2px 6px;border-radius:4px;'>"
241
+ f"{safe_output_path}</code></p>",
242
+ unsafe_allow_html=True,
243
+ )
244
+
245
+ run_id = body.get("mlflow_run_id")
246
+ if run_id and not _MLFLOW_DISABLED:
247
+ safe_run_id = _html.escape(str(run_id))
248
+ safe_url = _html.escape(_MLFLOW_URL, quote=True)
249
+ st.markdown(
250
+ f"<p class='mlflow-link'>MLflow run: "
251
+ f"<a href='{safe_url}/#/experiments/0/runs/{safe_run_id}' "
252
+ f"target='_blank' rel='noopener noreferrer'>{safe_run_id[:12]}…</a></p>",
253
+ unsafe_allow_html=True,
254
+ )
255
+ elif _MLFLOW_DISABLED:
256
+ st.markdown(
257
+ "<p style='color:#92400E;font-size:0.85rem;'>"
258
+ "MLflow tracking is disabled (NEUROBRIDGE_DISABLE_MLFLOW=1).</p>",
259
+ unsafe_allow_html=True,
260
+ )
261
+
262
+
263
+ def _render_sidebar(api_ok: bool, api_status: str) -> None:
264
+ with st.sidebar:
265
+ st.markdown("### System Status")
266
+ safe_api_status = _html.escape(api_status)
267
+ api_pill = (
268
+ f"<span class='status-pill status-ok'>API · {safe_api_status}</span>"
269
+ if api_ok
270
+ else f"<span class='status-pill status-down'>API · {safe_api_status}</span>"
271
+ )
272
+ mlflow_pill = (
273
+ "<span class='status-pill status-warn'>MLflow · disabled</span>"
274
+ if _MLFLOW_DISABLED
275
+ else "<span class='status-pill status-ok'>MLflow · tracking</span>"
276
+ )
277
+ st.markdown(api_pill + mlflow_pill, unsafe_allow_html=True)
278
+
279
+ st.markdown("### Endpoints")
280
+ st.markdown(
281
+ f"<p style='font-size:0.8rem;color:#475569;line-height:1.7;'>"
282
+ f"FastAPI · <code>{_API_URL}</code><br/>"
283
+ f"MLflow · <code>{_MLFLOW_URL}</code></p>",
284
+ unsafe_allow_html=True,
285
+ )
286
+
287
+ st.markdown("### About")
288
+ st.markdown(
289
+ "<p style='font-size:0.85rem;color:#475569;line-height:1.6;'>"
290
+ "Solving Data Drift, Missing Modalities, and Artifacts in clinical "
291
+ "biosignal pipelines. Three production modalities behind one FastAPI "
292
+ "surface, all runs tracked to MLflow.</p>",
293
+ unsafe_allow_html=True,
294
+ )
295
+
296
+
297
+ def _render_bbb_tab() -> None:
298
+ _render_section(
299
+ "MOLECULE — BBBP",
300
+ "Blood-Brain-Barrier permeability",
301
+ "Reads SMILES strings, validates with RDKit, and emits 2,048-bit "
302
+ "Morgan circular fingerprints (ECFP4-equivalent) ready for any "
303
+ "scikit-learn classifier.",
304
+ )
305
+ bbb_in = st.text_input("Input CSV path", "data/raw/bbbp.csv", key="bbb_in")
306
+ bbb_out = st.text_input("Output Parquet path", "data/processed/bbbp_features.parquet", key="bbb_out")
307
+ if st.button("Run BBB pipeline", type="primary", key="bbb_run"):
308
+ with st.spinner("Computing fingerprints…"):
309
+ try:
310
+ _render_result(_post("/pipeline/bbb", {
311
+ "input_path": bbb_in, "output_path": bbb_out,
312
+ }))
313
+ st.toast("BBB pipeline complete", icon="✅")
314
+ except httpx.HTTPStatusError as e:
315
+ st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
316
+ except httpx.RequestError as e:
317
+ st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
318
+
319
+
320
+ def _render_eeg_tab() -> None:
321
+ _render_section(
322
+ "SIGNAL — EEG",
323
+ "Electroencephalogram artifact removal",
324
+ "Bandpass-filters raw FIF/EDF recordings, removes EOG artifacts via "
325
+ "ICA decomposition, and extracts per-band PSD + statistical features "
326
+ "across fixed-duration epochs.",
327
+ )
328
+ eeg_in = st.text_input("Input FIF/EDF path", "data/raw/eeg.fif", key="eeg_in")
329
+ eeg_out = st.text_input("Output Parquet path", "data/processed/eeg_features.parquet", key="eeg_out")
330
+ if st.button("Run EEG pipeline", type="primary", key="eeg_run"):
331
+ with st.spinner("Filtering and running ICA…"):
332
+ try:
333
+ _render_result(_post("/pipeline/eeg", {
334
+ "input_path": eeg_in, "output_path": eeg_out,
335
+ }))
336
+ st.toast("EEG pipeline complete", icon="✅")
337
+ except httpx.HTTPStatusError as e:
338
+ st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
339
+ except httpx.RequestError as e:
340
+ st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
341
+
342
+
343
+ def _render_mri_tab() -> None:
344
+ _render_section(
345
+ "IMAGE — MRI",
346
+ "Multi-site harmonization via ComBat",
347
+ "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
348
+ "statistics, then harmonizes across acquisition sites with neuroHarmonize "
349
+ "to remove scanner-driven domain shift.",
350
+ )
351
+ mri_dir = st.text_input("Input NIfTI directory", "data/raw/mri/", key="mri_dir")
352
+ sites_csv = st.text_input("Sites CSV", "data/raw/mri/sites.csv", key="mri_sites")
353
+ mri_out = st.text_input("Output Parquet path", "data/processed/mri_features.parquet", key="mri_out")
354
+ if st.button("Run MRI pipeline", type="primary", key="mri_run"):
355
+ with st.spinner("Masking, ROI extraction, and ComBat harmonization…"):
356
+ try:
357
+ _render_result(_post("/pipeline/mri", {
358
+ "input_dir": mri_dir,
359
+ "sites_csv": sites_csv,
360
+ "output_path": mri_out,
361
+ }))
362
+ st.toast("MRI pipeline complete", icon="✅")
363
+ except httpx.HTTPStatusError as e:
364
+ st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
365
+ except httpx.RequestError as e:
366
+ st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
367
+
368
+
369
+ def main() -> None:
370
+ """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
371
+ st.set_page_config(
372
+ page_title="NeuroBridge Enterprise",
373
+ page_icon=None,
374
+ layout="wide",
375
+ initial_sidebar_state="expanded",
376
+ )
377
+ st.markdown(_CUSTOM_CSS, unsafe_allow_html=True)
378
+
379
+ api_ok, api_status = _check_api_health()
380
+ _render_brand_header()
381
+ _render_sidebar(api_ok, api_status)
382
+
383
+ if not api_ok:
384
+ st.warning(
385
+ f"⚠️ FastAPI surface is not reachable at `{_API_URL}` ({api_status}). "
386
+ "Pipeline runs will fail until the API service is up. "
387
+ "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
388
+ )
389
+
390
+ bbb_tab, eeg_tab, mri_tab = st.tabs([
391
+ "Molecule (BBB)",
392
+ "Signal (EEG)",
393
+ "Image (MRI)",
394
+ ])
395
+
396
+ with bbb_tab:
397
+ _render_bbb_tab()
398
+ with eeg_tab:
399
+ _render_eeg_tab()
400
+ with mri_tab:
401
+ _render_mri_tab()
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()
tests/frontend/__init__.py ADDED
File without changes
tests/frontend/test_app_import.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke-test that the Streamlit app module imports cleanly.
2
+
3
+ Streamlit UIs are hard to unit-test without `streamlit.testing` (which
4
+ spawns a headless app); for hackathon scope we settle for a clean import
5
+ + presence of `main()`. Manual UX testing via `streamlit run`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def test_app_module_imports() -> None:
11
+ from src.frontend import app # noqa: F401
12
+
13
+
14
+ def test_app_module_defines_main() -> None:
15
+ from src.frontend import app
16
+ assert hasattr(app, "main")
17
+ assert callable(app.main)