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
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:
@@ -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"]
|
||||
Generated
+2217
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
@@ -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>)}`)
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<main>{@render children()}</main>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = ({ params }) => ({ sampleId: params.id });
|
||||
@@ -0,0 +1,7 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": { "strict": true, "moduleResolution": "bundler" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({ plugins: [sveltekit()], server: { port: 5173 } });
|
||||
Reference in New Issue
Block a user