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:
+8
-1
@@ -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); } }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user