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
+360
View File
@@ -0,0 +1,360 @@
// this file is generated — do not edit it
/// <reference types="@sveltejs/kit" />
/**
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
*
* **_Private_ access:**
*
* - This module cannot be imported into client-side code
* - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
*
* For example, given the following build time environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
*
* console.log(ENVIRONMENT); // => "production"
* console.log(PUBLIC_BASE_URL); // => throws error during build
* ```
*
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
*/
declare module '$env/static/private' {
export const CLAUDE_CODE_MESSAGING_TOKEN: string;
export const NoDefaultCurrentDirectoryInExePath: string;
export const CLAUDE_EFFORT: string;
export const CLAUDE_CODE_ENTRYPOINT: string;
export const VSCODE_CRASH_REPORTER_PROCESS_TYPE: string;
export const NODE: string;
export const INIT_CWD: string;
export const SHELL: string;
export const npm_config_allow_scripts: string;
export const CLAUDE_PID: string;
export const CLAUDE_CODE_CHILD_SESSION: string;
export const TMPDIR: string;
export const HOMEBREW_REPOSITORY: string;
export const npm_config_global_prefix: string;
export const FPATH: string;
export const CLAUDE_AGENT_SDK_VERSION: string;
export const MallocNanoZone: string;
export const COLOR: string;
export const npm_config_noproxy: string;
export const npm_config_local_prefix: string;
export const PNPM_HOME: string;
export const GIT_EDITOR: string;
export const AI_AGENT: string;
export const USER: string;
export const COMMAND_MODE: string;
export const npm_config_globalconfig: string;
export const RAILWAY_HOME: string;
export const SSH_AUTH_SOCK: string;
export const __CF_USER_TEXT_ENCODING: string;
export const npm_execpath: string;
export const ELECTRON_RUN_AS_NODE: string;
export const PATH: string;
export const MCP_CONNECTION_NONBLOCKING: string;
export const npm_package_json: string;
export const _: string;
export const LaunchInstanceID: string;
export const npm_config_userconfig: string;
export const npm_config_init_module: string;
export const __CFBundleIdentifier: string;
export const npm_command: string;
export const PWD: string;
export const VSCODE_HANDLES_UNCAUGHT_ERRORS: string;
export const npm_lifecycle_event: string;
export const EDITOR: string;
export const VSCODE_ESM_ENTRYPOINT: string;
export const npm_package_name: string;
export const LANG: string;
export const npm_config_npm_version: string;
export const XPC_FLAGS: string;
export const MACH_PORT_RENDEZVOUS_PEER_VALDATION: string;
export const npm_config_node_gyp: string;
export const npm_package_version: string;
export const CLAUDE_CODE_ENABLE_TASKS: string;
export const XPC_SERVICE_NAME: string;
export const GEMINI_API_KEY: string;
export const CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: string;
export const SHLVL: string;
export const HOME: string;
export const CLAUDE_CODE_EXECPATH: string;
export const VSCODE_NLS_CONFIG: string;
export const APPLICATION_INSIGHTS_NO_STATSBEAT: string;
export const HOMEBREW_PREFIX: string;
export const npm_config_cache: string;
export const LOGNAME: string;
export const npm_lifecycle_script: string;
export const VSCODE_IPC_HOOK: string;
export const VSCODE_CODE_CACHE_PATH: string;
export const COREPACK_ENABLE_AUTO_PIN: string;
export const npm_config_user_agent: string;
export const VSCODE_PID: string;
export const CLAUDE_CODE_SESSION_ID: string;
export const INFOPATH: string;
export const HOMEBREW_CELLAR: string;
export const OSLogRateLimit: string;
export const CLAUDECODE: string;
export const CLAUDE_CODE_MESSAGING_SOCKET: string;
export const VSCODE_L10N_BUNDLE_LOCATION: string;
export const VSCODE_CWD: string;
export const SECURITYSESSIONID: string;
export const npm_node_execpath: string;
export const npm_config_prefix: string;
export const TEST: string;
export const VITEST: string;
export const NODE_ENV: string;
export const PROD: string;
export const DEV: string;
export const BASE_URL: string;
export const MODE: string;
}
/**
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
*
* **_Public_ access:**
*
* - This module _can_ be imported into client-side code
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
*
* For example, given the following build time environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
*
* console.log(ENVIRONMENT); // => throws error during build
* console.log(PUBLIC_BASE_URL); // => "http://site.com"
* ```
*
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
*/
declare module '$env/static/public' {
}
/**
* This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
*
* **_Private_ access:**
*
* - This module cannot be imported into client-side code
* - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
*
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
*
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
* >
* > ```env
* > MY_FEATURE_FLAG=
* > ```
* >
* > You can override `.env` values from the command line like so:
* >
* > ```sh
* > MY_FEATURE_FLAG="enabled" npm run dev
* > ```
*
* For example, given the following runtime environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { env } from '$env/dynamic/private';
*
* console.log(env.ENVIRONMENT); // => "production"
* console.log(env.PUBLIC_BASE_URL); // => undefined
* ```
*/
declare module '$env/dynamic/private' {
export const env: {
CLAUDE_CODE_MESSAGING_TOKEN: string;
NoDefaultCurrentDirectoryInExePath: string;
CLAUDE_EFFORT: string;
CLAUDE_CODE_ENTRYPOINT: string;
VSCODE_CRASH_REPORTER_PROCESS_TYPE: string;
NODE: string;
INIT_CWD: string;
SHELL: string;
npm_config_allow_scripts: string;
CLAUDE_PID: string;
CLAUDE_CODE_CHILD_SESSION: string;
TMPDIR: string;
HOMEBREW_REPOSITORY: string;
npm_config_global_prefix: string;
FPATH: string;
CLAUDE_AGENT_SDK_VERSION: string;
MallocNanoZone: string;
COLOR: string;
npm_config_noproxy: string;
npm_config_local_prefix: string;
PNPM_HOME: string;
GIT_EDITOR: string;
AI_AGENT: string;
USER: string;
COMMAND_MODE: string;
npm_config_globalconfig: string;
RAILWAY_HOME: string;
SSH_AUTH_SOCK: string;
__CF_USER_TEXT_ENCODING: string;
npm_execpath: string;
ELECTRON_RUN_AS_NODE: string;
PATH: string;
MCP_CONNECTION_NONBLOCKING: string;
npm_package_json: string;
_: string;
LaunchInstanceID: string;
npm_config_userconfig: string;
npm_config_init_module: string;
__CFBundleIdentifier: string;
npm_command: string;
PWD: string;
VSCODE_HANDLES_UNCAUGHT_ERRORS: string;
npm_lifecycle_event: string;
EDITOR: string;
VSCODE_ESM_ENTRYPOINT: string;
npm_package_name: string;
LANG: string;
npm_config_npm_version: string;
XPC_FLAGS: string;
MACH_PORT_RENDEZVOUS_PEER_VALDATION: string;
npm_config_node_gyp: string;
npm_package_version: string;
CLAUDE_CODE_ENABLE_TASKS: string;
XPC_SERVICE_NAME: string;
GEMINI_API_KEY: string;
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: string;
SHLVL: string;
HOME: string;
CLAUDE_CODE_EXECPATH: string;
VSCODE_NLS_CONFIG: string;
APPLICATION_INSIGHTS_NO_STATSBEAT: string;
HOMEBREW_PREFIX: string;
npm_config_cache: string;
LOGNAME: string;
npm_lifecycle_script: string;
VSCODE_IPC_HOOK: string;
VSCODE_CODE_CACHE_PATH: string;
COREPACK_ENABLE_AUTO_PIN: string;
npm_config_user_agent: string;
VSCODE_PID: string;
CLAUDE_CODE_SESSION_ID: string;
INFOPATH: string;
HOMEBREW_CELLAR: string;
OSLogRateLimit: string;
CLAUDECODE: string;
CLAUDE_CODE_MESSAGING_SOCKET: string;
VSCODE_L10N_BUNDLE_LOCATION: string;
VSCODE_CWD: string;
SECURITYSESSIONID: string;
npm_node_execpath: string;
npm_config_prefix: string;
TEST: string;
VITEST: string;
NODE_ENV: string;
PROD: string;
DEV: string;
BASE_URL: string;
MODE: string;
[key: `PUBLIC_${string}`]: undefined;
[key: `${string}`]: string | undefined;
}
}
/**
* This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
*
* **_Public_ access:**
*
* - This module _can_ be imported into client-side code
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
*
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
*
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
* >
* > ```env
* > MY_FEATURE_FLAG=
* > ```
* >
* > You can override `.env` values from the command line like so:
* >
* > ```sh
* > MY_FEATURE_FLAG="enabled" npm run dev
* > ```
*
* For example, given the following runtime environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://example.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { env } from '$env/dynamic/public';
* console.log(env.ENVIRONMENT); // => undefined, not public
* console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
* ```
*
* ```
*
* ```
*/
declare module '$env/dynamic/public' {
export const env: {
[key: `PUBLIC_${string}`]: string | undefined;
}
}
+1
View File
@@ -0,0 +1 @@
// See https://svelte.dev/docs/kit/environment-variables for more information
+38
View File
@@ -0,0 +1,38 @@
// in dev, this makes Vite inject its client as this module's first dependency,
// so that global constant replacements are installed before any other module
// (including user hooks) evaluates. In build it's inert.
import.meta.hot;
export { matchers } from './matchers.js';
export const nodes = [
() => import('./nodes/0'),
() => import('./nodes/1')
];
export const server_loads = [];
export const dictionary = {
};
export const hooks = {
handleError: (({ error }) => { console.error(error) }),
reroute: (() => {}),
transport: {}
};
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
export const hash = false;
export const decode = (type, value) => decoders[type](value);
export { default as root } from '../root.js';
export const get_error_template = () => import('../shared/error-template.js').then(m => m.default);
+1
View File
@@ -0,0 +1 @@
export const matchers = {};
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../web/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte";
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../web/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
@@ -0,0 +1 @@
export default ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n";
+50
View File
@@ -0,0 +1,50 @@
{
"compilerOptions": {
"paths": {
"$app/types": [
"./types/index.d.ts"
]
},
"rootDirs": [
"..",
"./types"
],
"verbatimModuleSyntax": true,
"isolatedModules": true,
"lib": [
"esnext",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
"target": "esnext"
},
"include": [
"ambient.d.ts",
"env.d.ts",
"non-ambient.d.ts",
"./types/**/$types.d.ts",
"../vite.config.js",
"../vite.config.ts",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../test/**/*.js",
"../test/**/*.ts",
"../test/**/*.svelte",
"../tests/**/*.js",
"../tests/**/*.ts",
"../tests/**/*.svelte"
],
"exclude": [
"../node_modules/**",
"../src/service-worker.js",
"../src/service-worker/**/*.js",
"../src/service-worker.ts",
"../src/service-worker/**/*.ts",
"../src/service-worker.d.ts",
"../src/service-worker/**/*.d.ts"
]
}
+32 -5
View File
@@ -12,11 +12,15 @@ import json
import logging
import os
import shutil
import time
import uuid
from collections import deque
from datetime import UTC, datetime
from functools import cache
from typing import Any
from sqlalchemy import update
from app.config import settings
from app.db import SessionLocal
from app.models import Job, JobStatus
@@ -24,7 +28,8 @@ from app.models import Job, JobStatus
logger = logging.getLogger(__name__)
PIPELINE_ENTRYPOINT = "/pipeline/main.nf"
LOG_TAIL_CHARS = 4000
PROGRESS_LINES = 12 # rolling window of pipeline output kept on the job
PROGRESS_INTERVAL_S = 3 # between writes, unless a new process starts
PUBLISH_TIMEOUT_S = 30
_watchers: set[asyncio.Task[None]] = set()
@@ -129,14 +134,36 @@ async def _run_local(job_id: uuid.UUID, vcf_uri: str, assembly: str) -> str:
async def _watch(job_id: uuid.UUID, proc: Any) -> None:
out, err = await proc.communicate()
if proc.returncode == 0:
"""Follow the pipeline: record progress while it runs, and the reason if it dies."""
tail: deque[str] = deque(maxlen=PROGRESS_LINES)
last_write = 0.0
if proc.stdout is not None: # stderr is merged into stdout
async for raw in proc.stdout:
line = raw.decode(errors="replace").rstrip()
if not line:
continue
tail.append(line)
now = time.monotonic()
# A new pipeline step is worth showing at once; otherwise throttle the writes.
if line.startswith("[PROCESS") or now - last_write >= PROGRESS_INTERVAL_S:
last_write = now
await _record_progress(job_id, "\n".join(tail))
returncode = await proc.wait()
if returncode == 0:
return # the loader marks the job succeeded
tail = ((out or b"") + (err or b"")).decode(errors="replace")[-LOG_TAIL_CHARS:]
async with SessionLocal() as session:
job = await session.get(Job, job_id)
if job is not None and job.status != JobStatus.succeeded:
job.status = JobStatus.failed
job.log = f"nextflow exited with code {proc.returncode}\n{tail}"
job.log = f"nextflow exited with code {returncode}\n" + "\n".join(tail)
job.finished_at = datetime.now(UTC)
await session.commit()
async def _record_progress(job_id: uuid.UUID, text: str) -> None:
"""Put the latest output on the job, leaving a job that has already finished alone."""
async with SessionLocal() as session:
await session.execute(
update(Job).where(Job.id == job_id, Job.status == JobStatus.running).values(log=text)
)
await session.commit()
+68 -7
View File
@@ -33,14 +33,29 @@ async def test_local_without_nextflow_fails_fast_with_instructions(
assert job["finished_at"] is not None
class FakeProcess:
def __init__(self, returncode: int, stderr: bytes) -> None:
self.returncode = returncode
self._stderr = stderr
self.pid = 4242
class FakeStdout:
"""Stands in for asyncio's StreamReader: async-iterates the process output."""
async def communicate(self) -> tuple[bytes, bytes]:
return b"", self._stderr
def __init__(self, lines: list[bytes]) -> None:
self._lines = lines
def __aiter__(self) -> "FakeStdout":
return self
async def __anext__(self) -> bytes:
if not self._lines:
raise StopAsyncIteration
return self._lines.pop(0)
class FakeProcess:
def __init__(self, returncode: int, output: bytes = b"", lines: list[bytes] | None = None) -> None:
self.returncode = returncode
self.pid = 4242
self.stdout = FakeStdout(lines if lines is not None else ([output] if output else []))
async def wait(self) -> int:
return self.returncode
@pytest.mark.usefixtures("db")
@@ -149,3 +164,49 @@ async def test_the_pipeline_gets_its_own_database_url(
await client.post(f"/api/samples/{sample_id}/annotate")
await events.drain()
assert launched["env"]["DATABASE_URL"] == "postgresql+asyncpg://u:[email protected]:5432/db"
@pytest.mark.usefixtures("db")
async def test_progress_is_recorded_while_the_pipeline_runs(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The UI needs something better than "running" for two minutes."""
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(settings, "cloudrun_job", None)
monkeypatch.setattr(events.shutil, "which", lambda _: "/usr/bin/nextflow")
async def fake_exec(*cmd: str, **kw: Any) -> FakeProcess:
return FakeProcess(0, lines=[
b"[PIPELINE] main.nf | profile=docker\n",
b"[PROCESS f2/5c13bd] NORMALISE (tiny)\n",
b"[PROCESS cc/d62082] VEP (tiny)\n",
])
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
await events.drain()
job = (await client.get(f"/api/jobs/{r.json()['id']}")).json()
assert "VEP (tiny)" in job["log"]
assert job["status"] == "running" # the loader is what marks it succeeded
@pytest.mark.usefixtures("db")
async def test_progress_never_overwrites_a_finished_job(client: AsyncClient) -> None:
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=JobStatus.succeeded)
s.add_all([sample, job])
await s.commit()
job_id = job.id
await events._record_progress(job_id, "[PROCESS 1/a] VEP (late)")
job_json = (await client.get(f"/api/jobs/{job_id}")).json()
assert job_json["status"] == "succeeded"
assert job_json["log"] is None
+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}