diff --git a/.gitignore b/.gitignore index 89f41ed8..3835bdc4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ lib/REVIEW_SUMMARY.md # misc .DS_Store *.pem +*.bak .vscode/ # debug diff --git a/app/(build-model)/plant/page.tsx b/app/(build-model)/plant/page.tsx index 9731cb00..a91b7848 100644 --- a/app/(build-model)/plant/page.tsx +++ b/app/(build-model)/plant/page.tsx @@ -22,6 +22,7 @@ import { useAuth } from '@/components/auth/AuthProvider'; import { RastGenomeJob, submitReconstructJobFromApi } from '@/lib/api/modelseed'; import { extractTrackedJobId, trackJob } from '@/lib/api/jobTracker'; import PatricGenomesTable from '@/components/build-model/PatricGenomesTable'; +import RastGenomePreviewDialog from '@/components/build-model/RastGenomePreviewDialog'; import RastGenomesTable from '@/components/build-model/RastGenomesTable'; import { PatricGenome } from '@/lib/api/patric'; @@ -102,6 +103,7 @@ export default function BuildModelPlantPage() { const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [plantseedDialogOpen, setPlantseedDialogOpen] = useState(false); + const [selectedRastJob, setSelectedRastJob] = useState(null); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { if (PLANTSEED_MAINTENANCE && newValue === 0) { @@ -230,6 +232,11 @@ export default function BuildModelPlantPage() { }; const handleRastGenomeSelect = (job: RastGenomeJob) => { + setSelectedRastJob(job); + }; + + const handleProceedFromRastPreview = (job: RastGenomeJob) => { + setSelectedRastJob(null); const genomeId = job.genome_id || job.id; void handleReferenceSubmit('rast', 'RAST', genomeId, job.genome_name); }; @@ -441,18 +448,25 @@ export default function BuildModelPlantPage() { - setPlantseedDialogOpen(false)} - maxWidth="sm" - fullWidth - > - - - - + setSelectedRastJob(null)} + /> + + setPlantseedDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + + + ); diff --git a/app/(reference-data)/biochem/reactions/page.tsx b/app/(reference-data)/biochem/reactions/page.tsx index c6c2f4ee..5c41e597 100644 --- a/app/(reference-data)/biochem/reactions/page.tsx +++ b/app/(reference-data)/biochem/reactions/page.tsx @@ -298,12 +298,13 @@ export default function ReactionsPage() { { field: 'definition', headerName: 'Equation', - width: 350, + flex: 1, + minWidth: 280, sortable: false, renderCell: (params) => ( - + - + ), }, { diff --git a/app/api/rast/jobs/route.ts b/app/api/rast/jobs/route.ts new file mode 100644 index 00000000..6161e19d --- /dev/null +++ b/app/api/rast/jobs/route.ts @@ -0,0 +1,83 @@ +/** + * Server-side proxy for RAST genome annotation jobs. + * + * Proxies GET /api/rast/jobs from the configured modelseed-api backend. + * Runs server-side so it can access PATRIC_TOKEN and avoid CORS restrictions. + * + * The upstream endpoint requires MODELSEED_RAST_DB_HOST to be set on the + * modelseed-api server. Returns 503 with a clear message when not configured. + */ +import { NextRequest, NextResponse } from 'next/server'; + +/** Build a deduplicated list of upstream URLs to try in priority order. */ +function buildUpstreamCandidates(): string[] { + const configured = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/+$/, ''); + const candidates = new Set(); + if (configured) { + candidates.add(`${configured}/api/rast/jobs`); + } + candidates.add('https://staging.modelseed.org/PMS/api/rast/jobs'); + candidates.add('https://modelseed.org/PMS/api/rast/jobs'); + return Array.from(candidates); +} + +const UPSTREAM_CANDIDATES = buildUpstreamCandidates(); + +export async function GET(request: NextRequest): Promise { + // Prefer the token from the client request; fall back to server-side env + const token = + request.headers.get('authorization') || + process.env.PATRIC_TOKEN; + + if (!token) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 }, + ); + } + + const headers: HeadersInit = { + Accept: 'application/json', + Authorization: token, + }; + + let lastStatus = 503; + let lastDetail: string | null = null; + + for (const url of UPSTREAM_CANDIDATES) { + try { + const res = await fetch(url, { headers, cache: 'no-store' }); + + if (res.ok) { + const data: unknown = await res.json(); + return NextResponse.json(data); + } + + // Capture the detail for the final error response + lastStatus = res.status; + try { + const body = await res.json() as Record; + lastDetail = typeof body.detail === 'string' ? body.detail : null; + } catch { + // ignore JSON parse failure on error body + } + + console.warn(`[rast/jobs proxy] ${url} → HTTP ${res.status}${lastDetail ? ': ' + lastDetail : ''}`); + } catch (err) { + console.warn( + `[rast/jobs proxy] ${url} unreachable:`, + err instanceof Error ? err.message : err, + ); + } + } + + // All upstreams failed — return the last known status/detail + return NextResponse.json( + { + error: 'RAST jobs service unavailable', + detail: lastDetail ?? 'All upstream endpoints failed', + jobs: [], + }, + { status: lastStatus }, + ); +} diff --git a/app/fba/[...path]/page.tsx b/app/fba/[...path]/page.tsx index 57ba727f..7b18966f 100644 --- a/app/fba/[...path]/page.tsx +++ b/app/fba/[...path]/page.tsx @@ -30,6 +30,7 @@ import { getModelFbaDataFromApi, getModelFbaFromApi } from '@/lib/api/modelseed' import { useAuth } from '@/components/auth/AuthProvider'; import { workspaceGet, workspaceLs, workspaceDownloadUrl, parseWorkspaceGetObject } from '@/lib/api/workspace'; import { USE_MODELSEED_API } from '@/lib/api/config'; +import ChemicalEquation from '@/components/ui/ChemicalEquation'; import DataControlHeader from '@/components/layout/DataControlHeader'; /* ---------- types ---------- */ @@ -623,6 +624,14 @@ export default function FbaPage({ params }: { params: Promise<{ path: string[] } }, }, { field: 'name', headerName: 'Name', width: 240 }, + { + field: 'equation', + headerName: 'Equation', + flex: 1, + minWidth: 280, + sortable: false, + renderCell: (params) => , + }, { field: 'flux', headerName: 'Flux', width: 120, type: 'number' }, { field: 'min', headerName: 'Min', width: 100, type: 'number' }, { field: 'max', headerName: 'Max', width: 100, type: 'number' }, diff --git a/app/gapfill/[...path]/page.tsx b/app/gapfill/[...path]/page.tsx index 79963da5..34234dfe 100644 --- a/app/gapfill/[...path]/page.tsx +++ b/app/gapfill/[...path]/page.tsx @@ -24,6 +24,7 @@ import { DataGrid, GridColDef, GridPaginationModel, GridSortModel } from '@mui/x import { listModelGapfillsFromApi } from '@/lib/api/modelseed'; import { workspaceGet, workspaceDownloadUrl, parseWorkspaceGetObject } from '@/lib/api/workspace'; import { USE_MODELSEED_API } from '@/lib/api/config'; +import ChemicalEquation from '@/components/ui/ChemicalEquation'; import DataControlHeader from '@/components/layout/DataControlHeader'; /* ---------- types ---------- */ @@ -317,6 +318,14 @@ export default function GapfillPage({ params }: { params: Promise<{ path: string }, }, { field: 'name', headerName: 'Name', width: 280 }, + { + field: 'equation', + headerName: 'Equation', + flex: 1, + minWidth: 280, + sortable: false, + renderCell: (params) => , + }, { field: 'direction', headerName: 'Direction', width: 120 }, { field: 'compartment', headerName: 'Compartment', width: 140 }, ], []); diff --git a/components/build-model/RastGenomePreviewDialog.tsx b/components/build-model/RastGenomePreviewDialog.tsx new file mode 100644 index 00000000..fd906d9a --- /dev/null +++ b/components/build-model/RastGenomePreviewDialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import { getRastGenomeData, RastGenomeJob } from '@/lib/api/modelseed'; + +interface RastGenomePreviewDialogProps { + open: boolean; + job: RastGenomeJob | null; + onProceed: (job: RastGenomeJob) => void; + onClose: () => void; +} + +export default function RastGenomePreviewDialog({ open, job, onProceed, onClose }: RastGenomePreviewDialogProps) { + const [genomeData, setGenomeData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !job) return; + + const genomeId = job.genome_id || job.id; + if (!genomeId) return; + + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + setGenomeData(null); + setError(null); + + getRastGenomeData(genomeId) + .then((data) => { + if (cancelled) return; + setGenomeData(data); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Failed to fetch genome data'); + setLoading(false); + }); + + return () => { cancelled = true; }; + }, [open, job]); + + const metadataRows: { label: string; value: string }[] = []; + if (genomeData) { + const fields: [string, string][] = [ + ['id', 'Genome ID'], + ['name', 'Name'], + ['genome_id', 'NCBI ID'], + ['source', 'Source'], + ['taxonomy', 'Taxonomy'], + ['domain', 'Domain'], + ['genetic_code', 'Genetic Code'], + ['gc_content', 'GC Content'], + ['dna_size', 'DNA Size'], + ['contig_count', 'Contigs'], + ['feature_count', 'Features'], + ['pegasus', 'Pegasus'], + ]; + for (const [key, label] of fields) { + const val = genomeData[key]; + if (val != null && val !== '') { + metadataRows.push({ label, value: String(val).slice(0, 300) }); + } + } + } + + return ( + + + RAST Genome Data — {job?.genome_name || job?.genome_id || job?.id} + + + {loading && ( + + + + )} + + {error && !loading && ( + + {error} + + Genome data is unavailable, but you can still proceed to build the model with default settings. + + + )} + + {genomeData && !loading && ( + <> + + Genome data loaded + + {genomeData.features && Array.isArray(genomeData.features) && ( + + Features: {genomeData.features.length.toLocaleString()} + {genomeData.contigs && Array.isArray(genomeData.contigs) + ? ` | Contigs: ${genomeData.contigs.length.toLocaleString()}` + : ''} + + )} + + + {metadataRows.map((row) => ( + + {row.label} + {row.value} + + ))} + +
+ + )} +
+ + + + +
+ ); +} diff --git a/lib/api/modelseed.ts b/lib/api/modelseed.ts index 24437078..30444fdc 100644 --- a/lib/api/modelseed.ts +++ b/lib/api/modelseed.ts @@ -62,6 +62,61 @@ export interface RastGenomeJob { type: 'Genome'; } +/** + * Fetch genome annotation data from RAST. + * + * Calls MSSeedSupportServer.get_rast_genome_data over JSON-RPC. + * Returns genome metadata including taxonomy, domain, features, and contigs. + * + * @param genomeId - RAST genome ID to fetch data for + * @returns Promise resolving to genome data record + */ +export async function getRastGenomeData(genomeId: string): Promise> { + const response = await fetch(MODELSEED_SUPPORT_URL, { + method: 'POST', + headers: withRawTokenAuth( + { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + true, + ), + body: JSON.stringify({ + version: '1.1', + method: 'MSSeedSupportServer.get_rast_genome_data', + id: 'get-rast-genome-data', + params: [{ genome_id: genomeId }], + }), + }); + + const { payload: rawPayload, rawText } = await parseJsonResponse(response); + const payload = rawPayload as RastJobsRpcResponse | null; + + if (!response.ok) { + if (payload?.error) { + throw new Error(payload.error.message || payload.error.error || 'RAST genome data fetch failed'); + } + throw new Error( + `RAST genome data fetch failed (${response.status})${rawText ? `: ${rawText}` : ''}`, + ); + } + + if (!payload) { + throw new Error('RAST genome data returned an empty or non-JSON response'); + } + + if (payload.error) { + throw new Error(payload.error.message || payload.error.error || 'RAST genome data fetch failed'); + } + + const result = Array.isArray(payload.result) ? payload.result[0] : payload.result; + if (!result || typeof result !== 'object') { + throw new Error('Unexpected RAST genome data format'); + } + + return result as Record; +} + export interface ModelDetailBundle { ref: string; data: Record; @@ -481,6 +536,67 @@ type RawRastJob = { * ``` */ export async function listRastGenomes(): Promise { + // Try José's modelseed-api endpoint first via the local proxy. + // Falls back to direct MSSS JSON-RPC if the proxy is unavailable. + try { + const token = getStoredAuthUsername(); + const headers: Record = { Accept: 'application/json' }; + if (token) { + headers['Authorization'] = token; + } + const proxyRes = await fetch('/api/rast/jobs', { headers }); + if (proxyRes.ok) { + const data: unknown = await proxyRes.json(); + if (Array.isArray(data)) { + return (data as RawRastJob[]) + .filter((job) => String(job.type ?? '') === 'Genome') + .map((job) => { + const id = String(job.id ?? ''); + const genomeId = String(job.genome_id ?? ''); + return { + id, + genome_id: genomeId, + genome_name: String(job.genome_name ?? genomeId ?? id), + contig_count: + typeof job.contig_count === 'number' + ? job.contig_count + : Number.isFinite(Number(job.contig_count)) + ? Number(job.contig_count) + : undefined, + mod_time: job.mod_time ? String(job.mod_time) : undefined, + type: 'Genome', + } satisfies RastGenomeJob; + }); + } + // Some backends wrap in { jobs: [...] } + const wrapped = data as Record; + if (wrapped.jobs && Array.isArray(wrapped.jobs)) { + return (wrapped.jobs as RawRastJob[]) + .filter((job) => String(job.type ?? '') === 'Genome') + .map((job) => { + const id = String(job.id ?? ''); + const genomeId = String(job.genome_id ?? ''); + return { + id, + genome_id: genomeId, + genome_name: String(job.genome_name ?? genomeId ?? id), + contig_count: + typeof job.contig_count === 'number' + ? job.contig_count + : Number.isFinite(Number(job.contig_count)) + ? Number(job.contig_count) + : undefined, + mod_time: job.mod_time ? String(job.mod_time) : undefined, + type: 'Genome', + } satisfies RastGenomeJob; + }); + } + } + } catch { + // Proxy unavailable — fall through to MSSS + } + + // Fallback: direct MSSS JSON-RPC (legacy path) const callRastList = async (method: string, params: Record) => { const response = await fetch(MODELSEED_SUPPORT_URL, { method: 'POST', @@ -502,8 +618,6 @@ export async function listRastGenomes(): Promise { const payload = rawPayload as RastJobsRpcResponse | null; if (!response.ok) { - // Some deployments return RPC JSON error payloads with HTTP 500. - // Preserve payload so caller can apply compatibility fallbacks. if (payload?.error) { return payload; } @@ -520,10 +634,7 @@ export async function listRastGenomes(): Promise { }; const candidateMethods = [ - // Legacy ModelSEED UI used `service=msSupport` + `method=list_rast_jobs`, - // which resolved to `MSSeedSupportServer.list_rast_jobs`. 'MSSeedSupportServer.list_rast_jobs', - // Keep compatibility fallbacks for any non-standard deployments. 'msSupport.list_rast_jobs', 'ms_fba.list_rast_jobs', ]; @@ -544,13 +655,9 @@ export async function listRastGenomes(): Promise { methodErrors.push( `${method} (${paramsLabel}): ${message || `error code ${attempt.error.code ?? 'unknown'}`}`, ); - // -32601 indicates "method not found" in most deployments. Some gateways also use it - // for "package not found". Either way, move to the next compatible method name. if (attempt.error.code === -32601) { break; } - // This backend error appears when owner cannot be resolved internally. - // Try the alternate param payload before failing. if ((attempt.error.message || '').includes('selectall_arrayref')) { continue; } @@ -570,8 +677,6 @@ export async function listRastGenomes(): Promise { } if (!payload) { - // Preserve legacy behavior: if backend-side RAST listing is broken, avoid hard failure - // in the Build Model tab and return an empty list with a clear warning in the console. if (methodErrors.some((entry) => entry.includes('selectall_arrayref'))) { console.warn( 'RAST list jobs backend returned selectall_arrayref errors. ' @@ -609,8 +714,7 @@ export async function listRastGenomes(): Promise { mod_time: job.mod_time ? String(job.mod_time) : undefined, type: 'Genome', } satisfies RastGenomeJob; - }) - .filter((job) => job.genome_id.length > 0 || job.id.length > 0); + }); } /** diff --git a/tests/e2e/build-model/rast-genomes.spec.ts b/tests/e2e/build-model/rast-genomes.spec.ts new file mode 100644 index 00000000..3868a122 --- /dev/null +++ b/tests/e2e/build-model/rast-genomes.spec.ts @@ -0,0 +1,128 @@ +import { test, expect, type Page, type Route } from '@playwright/test'; + +const PATRIC_TOKEN = process.env.PATRIC_TOKEN; + +const MOCK_RAST_JOBS = [ + { owner: 'seaver', contig_count: 6188, genome_size: 6007980, mod_time: '2012-05-08 23:08:29', project: 'seaver_6666666', genome_name: 'Unknown sp.', type: 'Genome', id: '50639', creation_time: '2012-05-03 16:34:53', genome_id: '6666666.16170' }, + { owner: 'seaver', contig_count: 1, genome_size: 4641652, mod_time: '2017-09-12 14:09:39', project: 'seaver_6666666', genome_name: 'Escherichia coli str. K12 substr. MG1655', type: 'Genome', id: '502220', creation_time: '2017-09-12 14:00:24', genome_id: '6666666.279675' }, +]; + +const MSSS_LIST_RESPONSE = { version: '1.1', result: [[...MOCK_RAST_JOBS]], id: 'list-rast-genomes' }; +const MSSS_EMPTY_RESPONSE = { version: '1.1', result: [[]], id: 'list-rast-genomes' }; +const PROXY_EMPTY_RESPONSE = { result: [[]] }; + +async function authenticatePage(page: Page, token: string): Promise { + await page.addInitScript((t: string) => { + window.localStorage.setItem('auth', JSON.stringify({ user_id: 'seaver', token: t, method: 'PATRIC' })); + }, token); +} + +async function mockMsssListRoute(page: Page, response: object = MSSS_LIST_RESPONSE): Promise { + await page.route('**/services/ms_fba', async (route: Route) => { + const postData = route.request().postDataJSON(); + if (postData?.method === 'MSSeedSupportServer.list_rast_jobs') { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }); + } else { + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ version: '1.1', error: { code: -32603, message: 'Mock error' }, id: postData?.id ?? 'mock' }) }); + } + }); +} + +async function mockProxyRoute(page: Page, response: object = PROXY_EMPTY_RESPONSE): Promise { + await page.route('**/api/rast/jobs', async (route: Route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }); + }); +} + +async function clickRastTab(page: Page): Promise { + await page.goto('/plant'); + await page.getByRole('tab', { name: /RAST Microbes/i }).click(); + await page.waitForTimeout(1000); +} + +test.describe('RAST Genomes Table — Build Model Page', () => { + + test.describe('Authenticated: RAST table loads and displays genome jobs', () => { + test.skip(!PATRIC_TOKEN, 'PATRIC_TOKEN not set in .env.local'); + + test.beforeEach(async ({ page }) => { + await authenticatePage(page, PATRIC_TOKEN!); + await mockMsssListRoute(page); + await mockProxyRoute(page); + }); + + test('displays RAST genome jobs in the data grid', async ({ page }) => { + await clickRastTab(page); + const grid = page.locator('.MuiDataGrid-root'); + await expect(grid).toBeVisible({ timeout: 10000 }); + await expect(grid.getByText('Escherichia coli str. K12 substr. MG1655')).toBeVisible({ timeout: 5000 }); + await expect(grid.getByText('6666666.279675')).toBeVisible({ timeout: 3000 }); + }); + + test('shows Build Model button for each row', async ({ page }) => { + await clickRastTab(page); + const buildButtons = page.locator('button:has-text("Build Model")'); + await expect(buildButtons.first()).toBeVisible({ timeout: 10000 }); + expect(await buildButtons.count()).toBeGreaterThanOrEqual(2); + }); + + test('handles empty RAST job list gracefully', async ({ page }) => { + await mockMsssListRoute(page, MSSS_EMPTY_RESPONSE); + await clickRastTab(page); + const banner = page.locator('text=RAST genome jobs are temporarily unavailable'); + await expect(banner).toBeVisible({ timeout: 10000 }); + }); + + test('handles MSSS server error gracefully', async ({ page }) => { + await page.route('**/services/ms_fba', async (route: Route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ version: '1.1', error: { code: -32603, message: 'Internal server error' }, id: 'list-rast-genomes' }) }); + }); + await clickRastTab(page); + const banner = page.locator('text=RAST genome jobs are temporarily unavailable'); + await expect(banner).toBeVisible({ timeout: 10000 }); + }); + + test('falls back to proxy when MSSS returns auth error', async ({ page }) => { + await page.route('**/services/ms_fba', async (route: Route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ version: '1.1', error: { code: -32603, message: 'Authentication required' }, id: 'list-rast-genomes' }) }); + }); + await page.route('**/api/rast/jobs', async (route: Route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MSSS_LIST_RESPONSE) }); + }); + await clickRastTab(page); + const grid = page.locator('.MuiDataGrid-root'); + await expect(grid).toBeVisible({ timeout: 10000 }); + await expect(grid.getByText('Escherichia coli str. K12 substr. MG1655')).toBeVisible({ timeout: 5000 }); + }); + + test('opens genome preview dialog on Build Model click', async ({ page }) => { + await clickRastTab(page); + const grid = page.locator('.MuiDataGrid-root'); + await expect(grid).toBeVisible({ timeout: 10000 }); + + await page.locator('button:has-text("Build Model")').first().click(); + await page.waitForTimeout(1500); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 8000 }); + await expect(dialog.getByText(/RAST Genome Data/)).toBeVisible({ timeout: 5000 }); + }); + }); + + test.describe('Unauthenticated: graceful empty state', () => { + test('shows AuthGuard message when not logged in', async ({ page }) => { + await mockMsssListRoute(page); + await page.goto('/plant'); + await expect(page.getByText('Authentication Required')).toBeVisible({ timeout: 10000 }); + }); + + test('does not throw console errors when unauthenticated', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/plant'); + await page.waitForTimeout(3000); + expect(errors.filter(e => !e.includes('ResizeObserver'))).toEqual([]); + }); + }); +});