Initial release: rarelens platform skeleton (AGPL-3.0)
ci / api (push) Failing after 10s
ci / terraform (push) Failing after 11s
ci / web (push) Failing after 35s
ci / pipeline (push) Failing after 2m29s
ci / images (api) (push) Skipped
ci / images (ml) (push) Skipped
ci / images (pipeline) (push) Skipped
ci / images (web) (push) Skipped

End-to-end variant interpretation platform for rare genetic disease research:
SvelteKit UI, FastAPI + PostgreSQL API, Nextflow/Ensembl VEP pipeline,
LightGBM pathogenicity scoring with MLflow, K8s/ArgoCD/GCP infrastructure.
Public test data only; no clinical claims.
This commit is contained in:
2026-09-11 16:55:35 +01:00
commit 5463f489a3
74 changed files with 4597 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
PUBLIC_API_URL=http://localhost:8000
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "build"]
+2217
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "rarelens-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^2.0.0"
}
}
+35
View File
@@ -0,0 +1,35 @@
/* Palette: pale slate paper, ink navy, and one plum accent reserved for pathogenicity. */
:root {
--paper: #f3f5f7;
--ink: #16243a;
--ink-soft: #52627a;
--line: #cfd6df;
--plum: #7a1f5c;
--plum-soft: #f0dbe8;
--amber: #b86a00;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--sans: 'Source Sans 3', system-ui, sans-serif;
}
html { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; }
body { margin: 0; }
main { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.5rem; }
h1 { font-weight: 600; font-size: 2rem; letter-spacing: -0.01em; margin: 0 0 0.5rem; }
h2 { font-weight: 600; font-size: 1.25rem; margin: 2rem 0 0.75rem; }
p.lede { color: var(--ink-soft); max-width: 60ch; margin: 0 0 2rem; }
a { color: var(--plum); text-underline-offset: 3px; }
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid var(--plum); outline-offset: 2px; }
button { font: inherit; padding: 0.5rem 1rem; border: 1.5px solid var(--ink); background: var(--ink); color: white; border-radius: 4px; cursor: pointer; }
button.quiet { background: transparent; color: var(--ink); }
input, select { font: inherit; padding: 0.45rem 0.6rem; border: 1.5px solid var(--line); border-radius: 4px; background: white; }
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--line); }
th, td { text-align: left; padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-weight: 600; color: var(--ink-soft); }
td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned digits matter here */
.score { display: inline-block; min-width: 3.2rem; text-align: right; padding: 0.1rem 0.4rem; border-radius: 3px; font-variant-numeric: tabular-nums; }
.score.high { background: var(--plum); color: white; }
.score.mid { background: var(--plum-soft); color: var(--plum); }
.empty { padding: 2rem; border: 1.5px dashed var(--line); color: var(--ink-soft); }
.status { font-weight: 600; }
.status.failed { color: #9a1b1b; }
.status.running, .status.queued { color: var(--amber); }
@media (prefers-reduced-motion: no-preference) { button { transition: background 120ms; } }
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import { PUBLIC_API_URL } from '$env/static/public';
export type Sample = { id: string; name: string; vcf_uri: string; assembly: string; created_at: string };
export type Job = { id: string; sample_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed'; created_at: string; finished_at: string | null };
export type Variant = {
id: number; chrom: string; pos: number; ref: string; alt: string; gene: string | null;
consequence: string | null; impact: string | null; hgvsc: string | null; hgvsp: string | null;
gnomad_af: number | null; clinvar_sig: string | null; prediction: { score: number; model_version: string } | null;
};
export type VariantPage = { items: Variant[]; total: number; limit: number; offset: number };
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${PUBLIC_API_URL}${path}`, { headers: { 'content-type': 'application/json' }, ...init });
if (!r.ok) throw new Error(`${r.status} ${r.statusText} on ${path}`);
return r.json() as Promise<T>;
}
export const api = {
samples: () => req<Sample[]>('/samples'),
createSample: (body: Pick<Sample, 'name' | 'vcf_uri'>) => req<Sample>('/samples', { method: 'POST', body: JSON.stringify(body) }),
jobsForSample: (sampleId: string) => req<Job[]>(`/samples/${sampleId}/jobs`),
annotate: (sampleId: string) => req<Job>(`/samples/${sampleId}/annotate`, { method: 'POST' }),
job: (id: string) => req<Job>(`/jobs/${id}`),
variants: (q: Record<string, string | number>) => req<VariantPage>(`/variants?${new URLSearchParams(q as Record<string, string>)}`)
};
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<main>{@render children()}</main>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, type Sample } from '$lib/api';
let samples = $state<Sample[]>([]);
let name = $state('');
let vcfUri = $state('');
let error = $state<string | null>(null);
async function refresh() { samples = await api.samples(); }
onMount(refresh);
async function add() {
error = null;
try { await api.createSample({ name, vcf_uri: vcfUri }); name = ''; vcfUri = ''; await refresh(); }
catch (e) { error = (e as Error).message; }
}
</script>
<h1>rarelens</h1>
<p class="lede">Annotate a VCF with Ensembl VEP, score each variant, and browse what came back. Public test data only.</p>
<h2>Add a sample</h2>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
<input placeholder="Sample name" bind:value={name} aria-label="Sample name" />
<input placeholder="gs://bucket/sample.vcf.gz or /data/example.vcf.gz" bind:value={vcfUri} aria-label="VCF path" style="flex:1; min-width: 20rem" />
<button onclick={add} disabled={!name || !vcfUri}>Add sample</button>
</div>
{#if error}<p role="alert">Could not add the sample: {error}</p>{/if}
<h2>Samples</h2>
{#if samples.length === 0}
<div class="empty">No samples yet. Add one above to run the annotation pipeline.</div>
{:else}
<table>
<thead><tr><th>Name</th><th>VCF</th><th>Assembly</th><th>Added</th></tr></thead>
<tbody>
{#each samples as s (s.id)}
<tr>
<td><a href="/samples/{s.id}">{s.name}</a></td>
<td class="hgvs">{s.vcf_uri}</td>
<td>{s.assembly}</td>
<td>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, type Job, type VariantPage } from '$lib/api';
let { data } = $props();
let job = $state<Job | null>(null);
let page = $state<VariantPage | null>(null);
let gene = $state('');
let impact = $state('');
let maxAf = $state('0.01');
let busy = $state(false);
onMount(async () => {
const jobs = await api.jobsForSample(data.sampleId);
const done = jobs.find(j => j.status === 'succeeded');
if (done) { job = done; await loadVariants(); }
else if (jobs.length) job = jobs[0];
});
async function runAnnotation() {
busy = true;
job = await api.annotate(data.sampleId);
const poll = setInterval(async () => {
if (!job) return;
job = await api.job(job.id);
if (job.status === 'succeeded' || job.status === 'failed') { clearInterval(poll); busy = false; if (job.status === 'succeeded') await loadVariants(); }
}, 3000);
}
async function loadVariants() {
if (!job) return;
const q: Record<string, string> = { job_id: job.id, max_af: maxAf, limit: '100' };
if (gene) q.gene = gene;
if (impact) q.impact = impact;
page = await api.variants(q);
}
const scoreClass = (s: number) => (s >= 0.8 ? 'score high' : s >= 0.5 ? 'score mid' : 'score');
</script>
<a href="/">All samples</a>
<h1>Sample {data.sampleId.slice(0, 8)}</h1>
{#if !job}
<button onclick={runAnnotation} disabled={busy}>Run VEP annotation</button>
{:else}
<p>Job <span class="hgvs">{job.id.slice(0, 8)}</span>: <span class="status {job.status}">{job.status}</span></p>
{/if}
{#if job?.status === 'succeeded'}
<h2>Variants</h2>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:0.75rem">
<input placeholder="Gene symbol" bind:value={gene} aria-label="Gene" />
<select bind:value={impact} aria-label="Impact">
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option><option>LOW</option><option>MODIFIER</option>
</select>
<label>Max gnomAD AF <input type="number" step="0.001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
<button class="quiet" onclick={loadVariants}>Apply filters</button>
</div>
{#if page && page.items.length === 0}
<div class="empty">No variants match these filters. Raise the allele frequency cap or clear the gene filter.</div>
{:else if page}
<p style="color:var(--ink-soft)">{page.total} variants, showing {page.items.length}</p>
<table>
<thead><tr><th>Position</th><th>Gene</th><th>Consequence</th><th>HGVS</th><th>gnomAD AF</th><th>ClinVar</th><th>Score</th></tr></thead>
<tbody>
{#each page.items as v (v.id)}
<tr>
<td class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</td>
<td>{v.gene ?? ''}</td>
<td>{v.consequence ?? ''}<br /><small style="color:var(--ink-soft)">{v.impact ?? ''}</small></td>
<td class="hgvs">{v.hgvsp ?? v.hgvsc ?? ''}</td>
<td>{v.gnomad_af?.toExponential(2) ?? 'absent'}</td>
<td>{v.clinvar_sig ?? ''}</td>
<td>{#if v.prediction}<span class={scoreClass(v.prediction.score)}>{v.prediction.score.toFixed(2)}</span>{:else}<span style="color:var(--ink-soft)">unscored</span>{/if}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
+2
View File
@@ -0,0 +1,2 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({ sampleId: params.id });
+7
View File
@@ -0,0 +1,7 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "strict": true, "moduleResolution": "bundler" }
}
+3
View File
@@ -0,0 +1,3 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [sveltekit()], server: { port: 5173 } });