Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ lib/REVIEW_SUMMARY.md
# misc
.DS_Store
*.pem
*.bak
.vscode/

# debug
Expand Down
38 changes: 26 additions & 12 deletions app/(build-model)/plant/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -102,6 +103,7 @@ export default function BuildModelPlantPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [plantseedDialogOpen, setPlantseedDialogOpen] = useState(false);
const [selectedRastJob, setSelectedRastJob] = useState<RastGenomeJob | null>(null);

const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
if (PLANTSEED_MAINTENANCE && newValue === 0) {
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -441,18 +448,25 @@ export default function BuildModelPlantPage() {
</Button>
</Box>

<Dialog
open={plantseedDialogOpen}
onClose={() => setPlantseedDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogActions>
<Button onClick={() => setPlantseedDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
<RastGenomePreviewDialog
open={selectedRastJob !== null}
job={selectedRastJob}
onProceed={handleProceedFromRastPreview}
onClose={() => setSelectedRastJob(null)}
/>

<Dialog
open={plantseedDialogOpen}
onClose={() => setPlantseedDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogActions>
<Button onClick={() => setPlantseedDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
</Box>
</AuthGuard >
);
Expand Down
7 changes: 4 additions & 3 deletions app/(reference-data)/biochem/reactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,13 @@ export default function ReactionsPage() {
{
field: 'definition',
headerName: 'Equation',
width: 350,
flex: 1,
minWidth: 280,
sortable: false,
renderCell: (params) => (
<TruncatedWithTooltip text={params.value} maxWidth={330}>
<Box sx={{ whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.5 }}>
<ChemicalEquation equation={params.value} />
</TruncatedWithTooltip>
</Box>
),
},
{
Expand Down
83 changes: 83 additions & 0 deletions app/api/rast/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<NextResponse> {
// 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<string, unknown>;
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 },
);
}
9 changes: 9 additions & 0 deletions app/fba/[...path]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------- */
Expand Down Expand Up @@ -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) => <ChemicalEquation equation={params.value} />,
},
{ field: 'flux', headerName: 'Flux', width: 120, type: 'number' },
{ field: 'min', headerName: 'Min', width: 100, type: 'number' },
{ field: 'max', headerName: 'Max', width: 100, type: 'number' },
Expand Down
9 changes: 9 additions & 0 deletions app/gapfill/[...path]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------- */
Expand Down Expand Up @@ -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) => <ChemicalEquation equation={params.value} />,
},
{ field: 'direction', headerName: 'Direction', width: 120 },
{ field: 'compartment', headerName: 'Compartment', width: 140 },
], []);
Expand Down
141 changes: 141 additions & 0 deletions components/build-model/RastGenomePreviewDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
RAST Genome Data — {job?.genome_name || job?.genome_id || job?.id}
</DialogTitle>
<DialogContent dividers>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}

{error && !loading && (
<Alert severity="warning" sx={{ mb: 2 }}>
{error}
<Typography variant="body2" sx={{ mt: 0.5 }}>
Genome data is unavailable, but you can still proceed to build the model with default settings.
</Typography>
</Alert>
)}

{genomeData && !loading && (
<>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'success.main' }}>
Genome data loaded
</Typography>
{genomeData.features && Array.isArray(genomeData.features) && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Features: {genomeData.features.length.toLocaleString()}
{genomeData.contigs && Array.isArray(genomeData.contigs)
? ` | Contigs: ${genomeData.contigs.length.toLocaleString()}`
: ''}
</Typography>
)}
<Table size="small">
<TableBody>
{metadataRows.map((row) => (
<TableRow key={row.label}>
<TableCell sx={{ fontWeight: 600, width: 180 }}>{row.label}</TableCell>
<TableCell sx={{ wordBreak: 'break-all' }}>{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
disabled={loading}
onClick={() => job && onProceed(job)}
>
{loading ? 'Loading...' : error ? 'Proceed anyway' : 'Proceed to Build Model'}
</Button>
</DialogActions>
</Dialog>
);
}
Loading
Loading