Files
rarelens/web/src/routes/cases/[id]/+page.svelte
T
Kemal Yaylali c25fb53666 feat(deploy): a Railway deployment of the analysed cases, behind one credential
Three services -- Postgres, API, UI -- with the API on Railway's private
network only, so the UI's /api proxy is the single public entry point and
there is no CORS.

The pipeline cannot run there. Nextflow shells out to `docker run` for VEP
and bcftools, and Railway gives you a container, not a Docker daemon. Rather
than leave a button that always fails, cases are annotated locally and copied
up by scripts/seed-remote.sh, and PUBLIC_PIPELINE_ENABLED=false hides the
analyse/score actions and the create-case form.

DATABASE_IDLE_CONNECTIONS=false is what makes idling work. Railway decides a
service is idle from its *outbound* traffic and sleeps it after ~5-10 minutes;
a pooled database connection is outbound traffic, so SQLAlchemy's default pool
would have kept the API awake and billable for ever. Setting it false switches
to NullPool, which costs a connection per request -- nothing at demo traffic,
the wrong trade under real load, hence the flag rather than a rewrite.

BASIC_AUTH_USER / BASIC_AUTH_PASSWORD put one shared credential in front of
the site. Nothing deployed is patient data, so this stops the URL being
wandered into rather than protecting anyone's privacy; unset, the site is
open, which is what local development wants. Compared in constant time, and
both halves of the credential are checked even when the first fails.
2026-09-12 12:23:19 +01:00

237 lines
7.8 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
import { poll } from '$lib/poll';
import { formatElapsed, latestStep } from '$lib/progress';
import { env } from '$env/dynamic/public';
import { missingEvidenceNote, needsScoring, plural } from '$lib/candidates';
import CandidateRow from '$lib/components/CandidateRow.svelte';
import Funnel from '$lib/components/Funnel.svelte';
import VariantPanel from '$lib/components/VariantPanel.svelte';
const POLL_MS = 3000;
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
let { data } = $props();
let kase = $state<Case | null>(null);
let job = $state<Job | null>(null);
let page = $state<CandidatePage | null>(null);
let detail = $state<VariantDetail | null>(null);
let gene = $state('');
let impact = $state('');
let maxAf = $state<number | null>(null);
let decisionFilter = $state('');
let busy = $state(false);
let scoring = $state(false);
let error = $state<string | null>(null);
let scoreNote = $state<string | null>(null);
let now = $state(Date.now());
let stopPolling: (() => void) | null = null;
const running = $derived((!!job && !finished(job)) || scoring);
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
const step = $derived(scoring ? 'scoring variants with the model' : latestStep(job?.log ?? null));
const termCount = $derived(kase?.phenotypes.length ?? 0);
// Annotation is expensive and scoring is not: a case annotated before a model existed should
// not need a five-minute re-run of VEP to get its score.
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items, page.evidence));
const evidenceNote = $derived(page ? missingEvidenceNote(page.evidence) : null);
// The deployed demo has no Nextflow and no Docker daemon to run VEP in, so the pipeline cannot
// run there. Hide the buttons rather than leave them to fail: the cases are already analysed.
const canAnalyse = env.PUBLIC_PIPELINE_ENABLED !== 'false';
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
$effect(() => {
if (!running) return;
const tick = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(tick);
});
onMount(async () => {
try {
kase = await api.case(data.caseId);
job = kase.latest_job;
if (job && !finished(job)) follow(job);
else await loadCandidates();
} catch (e) {
error = (e as Error).message;
}
});
onDestroy(() => stopPolling?.());
function follow(j: Job) {
stopPolling?.();
stopPolling = poll(() => api.job(j.id), {
intervalMs: POLL_MS,
done: finished,
onValue: (next) => {
job = next;
if (next.status === 'succeeded') scoreThenLoad();
},
onError: (e) => { error = `Lost track of the run: ${(e as Error).message}`; }
});
}
async function analyse() {
busy = true;
error = null;
page = null;
detail = null;
try {
job = await api.annotate(data.caseId);
if (!finished(job)) follow(job);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
// Annotation and scoring are one action to the user; the API keeps them separate.
async function scoreThenLoad() {
scoring = true;
try {
await api.score(data.caseId);
scoreNote = null;
} catch (e) {
// Scoring is optional: without a model the rank simply loses one of its four terms.
scoreNote = (e as Error).message;
} finally {
scoring = false;
}
await loadCandidates();
}
async function loadCandidates() {
try {
page = await api.candidates(data.caseId, {
gene,
impact,
maxAf,
state: decisionFilter as never,
limit: 100
});
} catch (e) {
error = (e as Error).message;
}
}
async function select(candidate: Candidate) {
try {
detail = await api.variant(candidate.variant.id);
} catch (e) {
error = (e as Error).message;
}
}
async function decided(updated: VariantDetail) {
detail = updated;
await loadCandidates();
}
</script>
<a href="/">All cases</a>
<div class="casehead">
<h1>{kase?.name ?? 'Case'}</h1>
<span class="chips">
{#each kase?.phenotypes ?? [] as term (term.hpo_id)}
<span class="chip match">{term.label}</span>
{/each}
{#if kase && kase.phenotypes.length === 0}
<span class="chip muted">no phenotype recorded — ranking falls back to rarity and consequence</span>
{/if}
</span>
<p class="muted provenance">
{kase?.assembly}
{#if job?.vep_version}· VEP {job.vep_version}{/if}
{#if page?.items[0]?.variant.prediction}· model {page.items[0].variant.prediction.model_version}{/if}
{#if job?.finished_at}· analysed {new Date(job.finished_at).toLocaleString('en-GB')}{/if}
</p>
</div>
{#if error}<p role="alert">{error}</p>{/if}
{#if scoreNote}
<p class="note">{scoreNote}</p>
{/if}
{#if evidenceNote}
<p class="note">{evidenceNote}</p>
{/if}
{#if running}
<p class="progress" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
Analysing for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
</p>
{:else}
<p class="actions">
{#if canAnalyse}
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
{#if unscored}
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
{/if}
{/if}
{#if page && page.funnel.total > 0}
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
{/if}
</p>
{/if}
{#if job?.status === 'failed' && job.log}
<pre class="log hgvs">{job.log}</pre>
{/if}
{#if page && page.funnel.total > 0}
<Funnel funnel={page.funnel} />
<div class="filters">
<input placeholder="Gene" bind:value={gene} aria-label="Gene" />
<select bind:value={impact} aria-label="Impact">
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option>
</select>
<label>Max AF <input type="number" step="0.0001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
<select bind:value={decisionFilter} aria-label="Decision">
<option value="">All decisions</option>
<option value="undecided">Undecided</option>
<option value="shortlisted">Shortlisted</option>
<option value="dismissed">Dismissed</option>
</select>
<button class="quiet" onclick={loadCandidates}>Apply</button>
</div>
<div class="triage">
<div class="candidates">
<p class="muted">{plural(page.total, 'candidate')} ranked by phenotype fit, rarity, consequence and model score</p>
{#each page.items as candidate, i (candidate.variant.id)}
<CandidateRow
{candidate}
rank={i + 1}
{termCount}
evidence={page.evidence}
selected={detail?.variant.id === candidate.variant.id}
onselect={() => select(candidate)} />
{:else}
<div class="empty">No candidates match these filters.</div>
{/each}
</div>
{#if detail && page}
<VariantPanel
{detail}
{termCount}
weights={page.weights}
evidence={page.evidence}
onclose={() => (detail = null)}
ondecided={decided} />
{/if}
</div>
{:else if page && !running}
<div class="empty">
{#if canAnalyse}
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
left against the phenotype.
{:else}
No results for this case. This deployment shows cases that were analysed beforehand; running
the pipeline needs Nextflow and a Docker daemon, which it does not have.
{/if}
</div>
{/if}