2026-05-15 — BLAST Jobs page no longer silently empty¶
Motivation¶
The BLAST Jobs page (/blast/jobs) was rendering "No BLAST jobs yet · Submit
your first search" even though the user had run 8 BLAST jobs that
afternoon (3 completed, 5 failed) against the cluster. Two failures stacked
on top of each other:
- Local-only data source. The page only reads from
api/routes/stubs.py
GET /api/blast/jobs, which queriesJobStateRepository(Azure Table Storage). The dashboard's local dev environment has noAZURE_TABLE_ENDPOINTset, so the route returns{"jobs": [], "degraded": true, "degraded_reason": "not_configured", "message": "Job state storage is not configured…"}. - The SPA dropped the
degraded/messagefields on the floor. It only consumedjobs[], so the user saw a perfectly normal "no rows yet" empty state with no hint that anything was wrong.
On top of that, the jobs the user actually cared about were never recorded
in the dashboard's local Table state at all — they were submitted directly
through the sibling elb-openapi execution-plane service
(elbacr01.azurecr.io/elb-openapi:3.4, exposed at http://20.249.147.217),
whose state lives in K8s ConfigMaps. There was no dashboard surface that
joined the two views.
User-facing change¶
- The BLAST Jobs page now also pulls from a new dashboard endpoint
GET /api/v1/elastic-blast/jobsthat proxies the external openapi service's/v1/jobslisting. Externally-submitted jobs appear in the list (with a synthesisedjob_titleof<program> · <db basename>andphasecopied from the externalstatus). - Local Table rows still win on
job_idcollision so they keep richer metadata (owner UPN, infrastructure, phase history). - When
/api/blast/jobsreportsdegraded: trueAND the merged list is empty, the empty-state card now shows an amber banner with thedegraded_reasonandmessageso the operator knows it is a config problem, not "you really have zero jobs". - If the external openapi proxy itself errors, the SPA shows a small muted "External ElasticBLAST OpenAPI is unreachable: …" line in the empty state.
API / IaC diff summary¶
- api/services/external_blast.py —
new
list_jobs()helper. Calls the external service's/v1/jobs(the legacy listing endpoint; the newer/api/v1/elastic-blast/...contract has submit/get/file but no list). Same auth + timeout + upstream-error handling as the other helpers in this module. - api/routes/elastic_blast.py —
new
@router.get("/jobs")(/api/v1/elastic-blast/jobs) gated byrequire_caller. Forwards toexternal_blast.list_jobs(). - web/src/pages/BlastJobs.tsx —
added a second
useQuery(['blast-jobs-external'], …)that calls the new proxy withretry: falseso a missingELB_OPENAPI_BASE_URLdoes not hammer the api with retries. MapperexternalToSummary()projects the external shape ontoBlastJobSummary.allJobsnow merges and sorts bycreated_at desc.degradedNoticederives from the/api/blast/jobsresponse and renders inside the empty-state card. - No Bicep / IaC change. No new env var (the proxy reuses
ELB_OPENAPI_BASE_URLandELB_OPENAPI_INTERNAL_TOKENthat the existingsubmit/getpaths already consume).
Validation evidence¶
uv run pytest -q api/tests→ 137 passed in 10.93s (no regression).npx tsc --noEmitinweb/→ clean (no TS errors).curl -H 'Authorization: Bearer __dev_bypass__' http://127.0.0.1:8080/api/v1/elastic-blast/jobs→ HTTP 200,count=8 jobs=8 statuses=['completed', 'failed']— matches the upstreamhttp://20.249.147.217/v1/jobsdirectly.- Dashboard
/api/blast/jobsstill returns its existingdegraded_reason: "not_configured"payload locally, which the SPA now surfaces instead of swallowing.
Notes for the next iteration¶
- The external job shape does not carry
owner_upn, so externally-submitted rows render without a user attribution. Once the openapi service starts recording the submitter (or the dashboard records every external submit into its own Table state), this will fill in automatically because local rows already win onjob_idcollision. phaseis just a copy of the openapistatusfor external rows, so filter buckets (running/completed/failed) line up with the same status strings the local route uses.- Delete still goes through
blastApi.deleteJobwhich targets/api/blast/jobs/{id}— for purely-external jobs that route will currently 404. A follow-up should either route to/api/v1/elastic-blast/jobs/{id}for external rows or hide the Trash affordance on rows we know are external-only.