Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
138 changes: 138 additions & 0 deletions components/build-model/RastGenomePreviewDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'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) {
setError('No genome ID available');
return;
}

setLoading(true);
setGenomeData(null);
setError(null);

getRastGenomeData(genomeId)
.then((data) => {
setGenomeData(data);
setLoading(false);
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to fetch genome data');
setLoading(false);
});
}, [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