feat(web): show what the pipeline is doing while a job runs

"running" for two and a half minutes tells the user nothing. The job page now shows a
spinner, the elapsed time, and the pipeline step Nextflow is actually on.

- the API streams the Nextflow output into jobs.log as it arrives, instead of keeping it
  only when the run dies. Writes are throttled to one every 3s, or immediately when a new
  process starts, and are skipped for a job that has already finished, so a late line
  cannot overwrite the loader's result.
- web/src/lib/progress.ts formats the elapsed time and picks the latest [PROCESS] line.
  No percentage: the pipeline cannot honestly estimate one.
- the spinner animates only under prefers-reduced-motion: no-preference.

Verified on a live run: the page showed "VEP (tiny)" for the duration, then the variant
table replaced it on success.

Tests: api 53, web 20; ruff, mypy, svelte-check clean.
This commit is contained in:
Kemal Yaylali
2026-09-12 07:46:44 +01:00
parent ae58e33fe2
commit 33e788122b
14 changed files with 638 additions and 13 deletions
+19
View File
@@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import { api, type Job, type VariantPage } from '$lib/api';
import { poll } from '$lib/poll';
import { formatElapsed, latestStep } from '$lib/progress';
const POLL_MS = 3000;
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
@@ -15,6 +16,18 @@
let busy = $state(false);
let error = $state<string | null>(null);
let stopPolling: (() => void) | null = null;
let now = $state(Date.now());
const running = $derived(!!job && !finished(job));
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
const step = $derived(latestStep(job?.log ?? null));
// 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 {
@@ -79,6 +92,12 @@
{#if job}
<p>Job <span class="hgvs">{job.id.slice(0, 8)}</span>: <span class="status {job.status}">{job.status}</span></p>
{#if running}
<p class="progress" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
Annotating for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
</p>
{/if}
{#if job.status === 'failed' && job.log}
<pre class="hgvs" style="white-space:pre-wrap; background:white; border:1px solid var(--line); padding:0.75rem; max-height:16rem; overflow:auto">{job.log}</pre>
{/if}