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.
237 lines
7.8 KiB
Svelte
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}
|