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
+8 -1
View File
@@ -32,4 +32,11 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned
.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; } }
.progress { display: flex; align-items: center; gap: 0.6rem; color: var(--ink-soft); margin: 0.25rem 0 1rem; }
.progress .hgvs { font-size: 0.85rem; }
.spinner { width: 0.9rem; height: 0.9rem; flex: none; border: 2px solid var(--line); border-top-color: var(--plum); border-radius: 50%; }
@media (prefers-reduced-motion: no-preference) {
button { transition: background 120ms; }
.spinner { animation: spin 0.9s linear infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { formatElapsed, latestStep } from './progress';
describe('formatElapsed', () => {
it('shows seconds under a minute', () => expect(formatElapsed(9_000)).toBe('9s'));
it('pads the seconds past a minute', () => expect(formatElapsed(64_000)).toBe('1m 04s'));
it('drops to minutes past an hour', () => expect(formatElapsed(3_725_000)).toBe('1h 02m'));
it('never goes negative when the clocks disagree', () => expect(formatElapsed(-5_000)).toBe('0s'));
});
describe('latestStep', () => {
it('names the process Nextflow is on', () => {
const log = [
'[PIPELINE] main.nf | profile=docker',
'[WORKDIR] /x/work',
'[PROCESS f2/5c13bd] NORMALISE (tiny)',
'[PROCESS cc/d62082] VEP (tiny)'
].join('\n');
expect(latestStep(log)).toBe('VEP (tiny)');
});
it('falls back to the last meaningful line', () => {
expect(latestStep('pulling ensemblorg/ensembl-vep\n\n')).toBe('pulling ensemblorg/ensembl-vep');
});
it('has nothing to say before the first line arrives', () => {
expect(latestStep(null)).toBeNull();
expect(latestStep(' \n\n')).toBeNull();
});
it('truncates a runaway line', () => {
expect(latestStep('x'.repeat(300))!.length).toBeLessThanOrEqual(120);
});
});
+24
View File
@@ -0,0 +1,24 @@
const MAX_STEP_CHARS = 120;
/** "9s", "1m 04s", "1h 02m". How long it has been going, not a made-up percentage. */
export function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}m`;
if (minutes) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
return `${seconds}s`;
}
/** The pipeline step Nextflow last started, else the last line it printed. */
export function latestStep(log: string | null): string | null {
const lines = (log ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return null;
const started = [...lines].reverse().find((line) => line.startsWith('[PROCESS'));
const step = started ? started.replace(/^\[PROCESS [^\]]*\]\s*/, '') : lines[lines.length - 1];
return step.slice(0, MAX_STEP_CHARS);
}
+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}