From 38691c324ebec33508e1e124b7a9e5a5f3cda0fe Mon Sep 17 00:00:00 2001 From: frapercan Date: Sat, 14 Mar 2026 22:06:10 +0100 Subject: [PATCH] fix: add missing frontend components and alembic migrations --- .gitignore | 4 + .../1f0ac8aa38a4_add_evaluation_set.py | 61 +++ .../47de89cf6fec_add_evaluation_result.py | 49 ++ apps/web/app/evaluation/page.tsx | 455 ++++++++++++++++++ apps/web/components/GoGraph.tsx | 333 +++++++++++++ apps/web/cytoscape-dagre.d.ts | 4 + scripts/deploy_vast.sh | 87 ++++ 7 files changed, 993 insertions(+) create mode 100644 alembic/versions/1f0ac8aa38a4_add_evaluation_set.py create mode 100644 alembic/versions/47de89cf6fec_add_evaluation_result.py create mode 100644 apps/web/app/evaluation/page.tsx create mode 100644 apps/web/components/GoGraph.tsx create mode 100644 apps/web/cytoscape-dagre.d.ts create mode 100755 scripts/deploy_vast.sh diff --git a/.gitignore b/.gitignore index bcd9fd6..f87aa10 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ logs/pids/ CLAUDE.md .claude/ + +# Local data +static/ +storage/ diff --git a/alembic/versions/1f0ac8aa38a4_add_evaluation_set.py b/alembic/versions/1f0ac8aa38a4_add_evaluation_set.py new file mode 100644 index 0000000..d816d02 --- /dev/null +++ b/alembic/versions/1f0ac8aa38a4_add_evaluation_set.py @@ -0,0 +1,61 @@ +"""add evaluation_set + +Revision ID: 1f0ac8aa38a4 +Revises: a7b8c9d0e1f2 +Create Date: 2026-03-12 22:13:05.918342 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '1f0ac8aa38a4' +down_revision: Union[str, Sequence[str], None] = 'a7b8c9d0e1f2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('evaluation_set', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('old_annotation_set_id', sa.UUID(), nullable=False), + sa.Column('new_annotation_set_id', sa.UUID(), nullable=False), + sa.Column('job_id', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('stats', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['job.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['new_annotation_set_id'], ['annotation_set.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['old_annotation_set_id'], ['annotation_set.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_evaluation_set_job_id'), 'evaluation_set', ['job_id'], unique=False) + op.create_index(op.f('ix_evaluation_set_new_annotation_set_id'), 'evaluation_set', ['new_annotation_set_id'], unique=False) + op.create_index(op.f('ix_evaluation_set_old_annotation_set_id'), 'evaluation_set', ['old_annotation_set_id'], unique=False) + op.drop_index(op.f('ix_go_term_relationship_child'), table_name='go_term_relationship') + op.drop_index(op.f('ix_go_term_relationship_parent'), table_name='go_term_relationship') + op.drop_index(op.f('ix_go_term_relationship_snapshot'), table_name='go_term_relationship') + op.create_index(op.f('ix_go_term_relationship_child_go_term_id'), 'go_term_relationship', ['child_go_term_id'], unique=False) + op.create_index(op.f('ix_go_term_relationship_ontology_snapshot_id'), 'go_term_relationship', ['ontology_snapshot_id'], unique=False) + op.create_index(op.f('ix_go_term_relationship_parent_go_term_id'), 'go_term_relationship', ['parent_go_term_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_go_term_relationship_parent_go_term_id'), table_name='go_term_relationship') + op.drop_index(op.f('ix_go_term_relationship_ontology_snapshot_id'), table_name='go_term_relationship') + op.drop_index(op.f('ix_go_term_relationship_child_go_term_id'), table_name='go_term_relationship') + op.create_index(op.f('ix_go_term_relationship_snapshot'), 'go_term_relationship', ['ontology_snapshot_id'], unique=False) + op.create_index(op.f('ix_go_term_relationship_parent'), 'go_term_relationship', ['parent_go_term_id'], unique=False) + op.create_index(op.f('ix_go_term_relationship_child'), 'go_term_relationship', ['child_go_term_id'], unique=False) + op.drop_index(op.f('ix_evaluation_set_old_annotation_set_id'), table_name='evaluation_set') + op.drop_index(op.f('ix_evaluation_set_new_annotation_set_id'), table_name='evaluation_set') + op.drop_index(op.f('ix_evaluation_set_job_id'), table_name='evaluation_set') + op.drop_table('evaluation_set') + # ### end Alembic commands ### diff --git a/alembic/versions/47de89cf6fec_add_evaluation_result.py b/alembic/versions/47de89cf6fec_add_evaluation_result.py new file mode 100644 index 0000000..e7c0792 --- /dev/null +++ b/alembic/versions/47de89cf6fec_add_evaluation_result.py @@ -0,0 +1,49 @@ +"""add evaluation_result + +Revision ID: 47de89cf6fec +Revises: 1f0ac8aa38a4 +Create Date: 2026-03-12 22:27:34.042479 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '47de89cf6fec' +down_revision: Union[str, Sequence[str], None] = '1f0ac8aa38a4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('evaluation_result', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('evaluation_set_id', sa.UUID(), nullable=False), + sa.Column('prediction_set_id', sa.UUID(), nullable=False), + sa.Column('job_id', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('results', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint(['evaluation_set_id'], ['evaluation_set.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['job_id'], ['job.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['prediction_set_id'], ['prediction_set.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_evaluation_result_evaluation_set_id'), 'evaluation_result', ['evaluation_set_id'], unique=False) + op.create_index(op.f('ix_evaluation_result_job_id'), 'evaluation_result', ['job_id'], unique=False) + op.create_index(op.f('ix_evaluation_result_prediction_set_id'), 'evaluation_result', ['prediction_set_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_evaluation_result_prediction_set_id'), table_name='evaluation_result') + op.drop_index(op.f('ix_evaluation_result_job_id'), table_name='evaluation_result') + op.drop_index(op.f('ix_evaluation_result_evaluation_set_id'), table_name='evaluation_result') + op.drop_table('evaluation_result') + # ### end Alembic commands ### diff --git a/apps/web/app/evaluation/page.tsx b/apps/web/app/evaluation/page.tsx new file mode 100644 index 0000000..fbc416a --- /dev/null +++ b/apps/web/app/evaluation/page.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { listAnnotationSets, listPredictionSets, baseUrl } from "@/lib/api"; +import type { AnnotationSet, PredictionSet } from "@/lib/api"; + +const labelClass = "block text-sm font-medium text-gray-700 mb-1"; +const selectClass = + "w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"; +const btnPrimary = + "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"; +const btnSecondary = + "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 transition-colors"; + +type NsMetrics = { + fmax: number; + precision: number; + recall: number; + tau: number; + coverage: number; + n_proteins?: number; +}; + +type SettingResults = Record; // BPO | MFO | CCO + +type EvaluationResult = { + id: string; + evaluation_set_id: string; + prediction_set_id: string; + job_id: string | null; + created_at: string; + results: Record; // NK | LK | PK +}; + +type EvaluationSet = { + id: string; + old_annotation_set_id: string; + new_annotation_set_id: string; + job_id: string | null; + created_at: string; + stats: { + delta_proteins?: number; + nk_proteins?: number; + lk_proteins?: number; + nk_annotations?: number; + lk_annotations?: number; + known_terms_count?: number; + }; +}; + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${baseUrl()}${path}`, { cache: "no-store", ...init }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +const listEvaluationSets = () => apiFetch("/annotations/evaluation-sets"); +const listResults = (evalId: string) => + apiFetch(`/annotations/evaluation-sets/${evalId}/results`); + +function setLabel(s: AnnotationSet) { + const date = new Date(s.created_at).toLocaleDateString(); + const count = s.annotation_count != null ? ` · ${s.annotation_count.toLocaleString()} ann.` : ""; + return `[${s.source.toUpperCase()}] ${s.source_version ?? "—"} · ${date}${count}`; +} + +function predLabel(p: PredictionSet) { + const date = new Date(p.created_at).toLocaleDateString(); + const count = p.prediction_count != null ? ` · ${p.prediction_count.toLocaleString()} preds.` : ""; + return `${p.id.slice(0, 8)}… · ${date}${count}`; +} + +function evalLabel(e: EvaluationSet, annotationSets: AnnotationSet[]) { + const date = new Date(e.created_at).toLocaleDateString(); + const oldSet = annotationSets.find((a) => a.id === e.old_annotation_set_id); + const newSet = annotationSets.find((a) => a.id === e.new_annotation_set_id); + const delta = e.stats.delta_proteins ?? "?"; + return `${oldSet?.source_version ?? "old"} → ${newSet?.source_version ?? "new"} · ${delta} delta proteins · ${date}`; +} + +function DownloadLink({ href, label, filename }: { href: string; label: string; filename: string }) { + return ( + + ↓ {label} + + ); +} + +function StatBadge({ label, value }: { label: string; value: number | undefined }) { + return ( +
+
+ {value != null ? value.toLocaleString() : "—"} +
+
{label}
+
+ ); +} + +const NS_LABELS: Record = { + BPO: "Biological Process", + MFO: "Molecular Function", + CCO: "Cellular Component", +}; + +const SETTING_COLORS: Record = { + NK: "bg-purple-50 border-purple-200", + LK: "bg-blue-50 border-blue-200", + PK: "bg-green-50 border-green-200", +}; + +function ResultsTable({ results }: { results: Record }) { + const settings = ["NK", "LK", "PK"].filter((s) => results[s] && Object.keys(results[s]).length > 0); + if (settings.length === 0) return

No results computed.

; + + return ( +
+ {settings.map((setting) => ( +
+
{setting}
+
+ {["BPO", "MFO", "CCO"].map((ns) => { + const m = results[setting]?.[ns]; + if (!m) return null; + return ( +
+
{NS_LABELS[ns]}
+
+
+ Fmax + {m.fmax.toFixed(3)} +
+
+ Precision + {m.precision.toFixed(3)} +
+
+ Recall + {m.recall.toFixed(3)} +
+
+ Coverage + {(m.coverage * 100).toFixed(1)}% +
+
+ τ + {m.tau.toFixed(2)} +
+
+
+ ); + })} +
+
+ ))} +
+ ); +} + +function EvaluationSetCard({ + e, + annotationSets, + predictionSets, + isSelected, + onSelect, +}: { + e: EvaluationSet; + annotationSets: AnnotationSet[]; + predictionSets: PredictionSet[]; + isSelected: boolean; + onSelect: () => void; +}) { + const [results, setResults] = useState([]); + const [loadingResults, setLoadingResults] = useState(false); + const [predSetId, setPredSetId] = useState(""); + const [maxDistance, setMaxDistance] = useState(""); + const [running, setRunning] = useState(false); + const [runError, setRunError] = useState(""); + + useEffect(() => { + if (!isSelected) return; + setLoadingResults(true); + listResults(e.id).then(setResults).finally(() => setLoadingResults(false)); + }, [isSelected, e.id]); + + async function handleRun() { + if (!predSetId) return; + setRunning(true); + setRunError(""); + try { + const body: Record = { prediction_set_id: predSetId }; + if (maxDistance) body.max_distance = parseFloat(maxDistance); + await apiFetch(`/annotations/evaluation-sets/${e.id}/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + // Refresh results after a short delay + setTimeout(() => { + listResults(e.id).then(setResults); + }, 2000); + } catch (err: any) { + setRunError(err.message ?? "Unknown error"); + } finally { + setRunning(false); + } + } + + return ( +
+ {/* Header */} +
+
{evalLabel(e, annotationSets)}
+
+ + + +
+
+ + {isSelected && ( +
+ + {/* Downloads */} +
+

Ground truth files

+
+ + + + +
+
+ + {/* Run evaluation */} +
+

Run CAFA evaluator

+
+
+ + +
+
+ + setMaxDistance(ev.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ {runError && ( +

{runError}

+ )} + +
+ + {/* Results */} +
+

Results

+ {loadingResults ? ( +

Loading…

+ ) : results.length === 0 ? ( +

No evaluations run yet.

+ ) : ( +
+ {results.map((r) => ( +
+
+
+ Pred: {r.prediction_set_id.slice(0, 8)}… · {new Date(r.created_at).toLocaleString()} +
+ + ↓ Artifacts (.zip) + +
+ +
+ ))} +
+ )} +
+
+ )} +
+ ); +} + +export default function EvaluationPage() { + const [annotationSets, setAnnotationSets] = useState([]); + const [predictionSets, setPredictionSets] = useState([]); + const [evaluationSets, setEvaluationSets] = useState([]); + const [loading, setLoading] = useState(true); + + const [oldSetId, setOldSetId] = useState(""); + const [newSetId, setNewSetId] = useState(""); + const [generating, setGenerating] = useState(false); + const [genError, setGenError] = useState(""); + const [selectedEvalId, setSelectedEvalId] = useState(""); + + const reload = () => + Promise.all([listAnnotationSets(), listPredictionSets(), listEvaluationSets()]) + .then(([ann, pred, ev]) => { + setAnnotationSets(ann); + setPredictionSets(pred); + setEvaluationSets(ev); + }) + .finally(() => setLoading(false)); + + useEffect(() => { reload(); }, []); + + const goaSets = annotationSets.filter((s) => s.source === "goa"); + const canGenerate = oldSetId && newSetId && oldSetId !== newSetId; + + async function handleGenerate() { + setGenerating(true); + setGenError(""); + try { + await apiFetch("/annotations/evaluation-sets/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ old_annotation_set_id: oldSetId, new_annotation_set_id: newSetId }), + }); + setOldSetId(""); + setNewSetId(""); + await reload(); + } catch (e: any) { + setGenError(e.message ?? "Unknown error"); + } finally { + setGenerating(false); + } + } + + if (loading) return
Loading…
; + + return ( +
+

CAFA Evaluation

+ + {/* ── Generate Evaluation Set ───────────────────────────────── */} +
+
+

New Evaluation Set

+

+ Computes the delta between two GOA releases. Applies experimental evidence + filtering and NOT-qualifier propagation through the GO DAG. +

+
+
+
+ + +
+
+ + +
+
+ {oldSetId && newSetId && oldSetId === newSetId && ( +

Old and new sets must be different.

+ )} + {genError && ( +

{genError}

+ )} + +
+ + {/* ── Evaluation Sets ───────────────────────────────────────── */} + {evaluationSets.length > 0 && ( +
+

Evaluation Sets

+ {evaluationSets.map((e) => ( + setSelectedEvalId(e.id === selectedEvalId ? "" : e.id)} + /> + ))} +
+ )} + + {/* ── Evaluator command reference ───────────────────────────── */} +
+

Manual evaluator command

+
+{`python -m cafaeval go-basic.obo predictions/ ground_truth_NK.tsv -out_dir results/NK
+python -m cafaeval go-basic.obo predictions/ ground_truth_LK.tsv -out_dir results/LK
+python -m cafaeval go-basic.obo predictions/ ground_truth_PK.tsv -known known_terms.tsv -out_dir results/PK`}
+        
+
+
+ ); +} diff --git a/apps/web/components/GoGraph.tsx b/apps/web/components/GoGraph.tsx new file mode 100644 index 0000000..dab1ced --- /dev/null +++ b/apps/web/components/GoGraph.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { GoSubgraph } from "@/lib/api"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function truncate(text: string, max: number) { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +// ── node classification ─────────────────────────────────────────────────────── + +function classifyNode( + goId: string, + isQuery: boolean, + knownGoIds?: Set, + predictedGoIds?: Set, +): "both" | "predicted_only" | "known_only" | "ancestor" { + if (!isQuery) return "ancestor"; + const isKnown = knownGoIds?.has(goId) ?? false; + const isPredicted = predictedGoIds?.has(goId) ?? true; + if (isKnown && isPredicted) return "both"; + if (isKnown) return "known_only"; + return "predicted_only"; +} + +// ── constants ──────────────────────────────────────────────────────────────── + +const ASPECT_LABELS: Record = { + F: "Molecular Function", + P: "Biological Process", + C: "Cellular Component", +}; +const ASPECT_TAB_COLORS: Record = { + F: "text-purple-700 border-purple-600", + P: "text-green-700 border-green-600", + C: "text-orange-700 border-orange-600", +}; + +const NODE_LEGEND: { kind: string; label: string; bg: string; border: string }[] = [ + { kind: "both", label: "Predicted + Known", bg: "#16a34a", border: "#14532d" }, + { kind: "predicted_only", label: "Predicted only", bg: "#2563eb", border: "#1e3a8a" }, + { kind: "known_only", label: "Known only", bg: "#d97706", border: "#92400e" }, + { kind: "ancestor", label: "Ancestor", bg: "#f8fafc", border: "#94a3b8" }, +]; + +// ── cytoscape style ─────────────────────────────────────────────────────────── + +const CY_STYLE = [ + { + selector: "node", + style: { + label: "data(label)", + "text-valign": "center", + "text-halign": "center", + "text-wrap": "wrap", + "text-max-width": "130px", + "font-size": "9px", + shape: "roundrectangle", + width: "label", + height: "label", + padding: "8px", + "border-width": 1.5, + "background-color": "#f8fafc", + "border-color": "#94a3b8", + color: "#475569", + }, + }, + // ── node kinds ── + { + selector: "node.both", + style: { "background-color": "#16a34a", "border-color": "#14532d", color: "#fff" }, + }, + { + selector: "node.predicted_only", + style: { "background-color": "#2563eb", "border-color": "#1e3a8a", color: "#fff" }, + }, + { + selector: "node.known_only", + style: { "background-color": "#d97706", "border-color": "#92400e", color: "#fff" }, + }, + { + selector: "node.ancestor", + style: { opacity: 0.7 }, + }, + // ── query emphasis ── + { + selector: "node.query", + style: { "border-width": 3, "font-weight": "bold" }, + }, + // ── edges ── + { + selector: "edge", + style: { + "line-color": "#94a3b8", + "target-arrow-color": "#94a3b8", + "target-arrow-shape": "triangle", + "arrow-scale": 0.8, + "curve-style": "bezier", + width: 1.5, + }, + }, + { + selector: 'edge[relation_type = "part_of"]', + style: { "line-style": "dashed", "line-color": "#f59e0b", "target-arrow-color": "#f59e0b" }, + }, + { + selector: 'edge[relation_type *= "regulates"]', + style: { "line-style": "dotted", "line-color": "#60a5fa", "target-arrow-color": "#60a5fa" }, + }, + // ── interaction ── + { + selector: ".faded", + style: { opacity: 0.12 }, + }, + { + selector: "node:selected", + style: { "border-color": "#1d4ed8", "border-width": 3 }, + }, +]; + +// ── tooltip state type ──────────────────────────────────────────────────────── + +type TooltipState = { + x: number; y: number; + goId: string; name: string; kind: string; isQuery: boolean; +} | null; + +// ── component ──────────────────────────────────────────────────────────────── + +interface Props { + subgraph: GoSubgraph; + knownGoIds?: Set; + predictedGoIds?: Set; + height?: number; +} + +export default function GoGraph({ subgraph, knownGoIds, predictedGoIds, height = 420 }: Props) { + const aspectsWithNodes = (["F", "P", "C"] as const).filter((asp) => + subgraph.nodes.some((n) => n.aspect === asp) + ); + const [activeAspect, setActiveAspect] = useState(aspectsWithNodes[0] ?? "F"); + const [tooltip, setTooltip] = useState(null); + + const containerRef = useRef(null); + const cyRef = useRef(null); + + useEffect(() => { + if (aspectsWithNodes.length > 0 && !aspectsWithNodes.includes(activeAspect as any)) { + setActiveAspect(aspectsWithNodes[0]); + } + }, [subgraph]); + + useEffect(() => { + if (!containerRef.current || subgraph.nodes.length === 0) return; + + const nodeIds = new Set( + subgraph.nodes.filter((n) => n.aspect === activeAspect).map((n) => String(n.id)) + ); + if (nodeIds.size === 0) return; + + const filteredEdges = subgraph.edges.filter( + (e) => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)) + ); + + let cy: any; + + async function init() { + const cytoscape = (await import("cytoscape")).default; + const dagre = (await import("cytoscape-dagre")).default; + cytoscape.use(dagre); + + const elements: any[] = []; + + for (const n of subgraph.nodes.filter((n) => n.aspect === activeAspect)) { + const kind = classifyNode(n.go_id, n.is_query, knownGoIds, predictedGoIds); + elements.push({ + data: { + id: String(n.id), + label: `${n.go_id}\n${truncate(n.name ?? "", 28)}`, + go_id: n.go_id, + full_name: n.name ?? "", + is_query: n.is_query, + kind, + }, + classes: `${kind}${n.is_query ? " query" : " context"}`, + }); + } + + for (const e of filteredEdges) { + // API stores edges as child→parent; invert for TB layout (root at top) + elements.push({ + data: { + id: `e-${e.source}-${e.target}-${e.relation_type}`, + source: String(e.target), + target: String(e.source), + relation_type: e.relation_type, + }, + classes: e.relation_type, + }); + } + + if (cyRef.current) cyRef.current.destroy(); + + cy = cytoscape({ + container: containerRef.current, + elements, + style: CY_STYLE as any, + layout: { + name: "dagre", + rankDir: "TB", + nodeSep: 50, + rankSep: 80, + padding: 24, + } as any, + minZoom: 0.2, + maxZoom: 2.5, + wheelSensitivity: 0.2, + userZoomingEnabled: true, + userPanningEnabled: true, + boxSelectionEnabled: false, + }); + + cy.ready(() => { cy.fit(undefined, 24); cy.center(); }); + + // ── click: highlight neighborhood ── + cy.on("tap", "node", (evt: any) => { + const node = evt.target; + cy.elements().removeClass("faded"); + cy.elements().difference(node.closedNeighborhood()).addClass("faded"); + }); + cy.on("tap", (evt: any) => { + if (evt.target === cy) cy.elements().removeClass("faded"); + }); + + // ── hover tooltip ── + cy.on("mouseover", "node", (evt: any) => { + const node = evt.target; + const pos = evt.renderedPosition; + const bb = containerRef.current!.getBoundingClientRect(); + setTooltip({ + x: pos.x, + y: pos.y, + goId: node.data("go_id"), + name: node.data("full_name"), + kind: node.data("kind"), + isQuery: node.data("is_query"), + }); + }); + cy.on("mouseout", "node", () => setTooltip(null)); + + cyRef.current = cy; + } + + init(); + + return () => { + setTooltip(null); + if (cyRef.current) { cyRef.current.destroy(); cyRef.current = null; } + }; + }, [subgraph, activeAspect, knownGoIds, predictedGoIds]); + + if (subgraph.nodes.length === 0) { + return ( +
+ No graph data available. +
+ ); + } + + return ( +
+ {/* Aspect tabs */} + {aspectsWithNodes.length > 1 && ( +
+ {aspectsWithNodes.map((asp) => ( + + ))} +
+ )} + + {/* Canvas + tooltip */} +
+
+ + {tooltip && ( +
+

{tooltip.goId}

+ {tooltip.name &&

{tooltip.name}

} +

{tooltip.kind.replace(/_/g, " ")}

+
+ )} +
+ + {/* Legend */} +
+ Nodes: + {NODE_LEGEND.map(({ kind, label, bg, border }) => ( + + + {label} + + ))} + + + is_a + + + part_of + + + regulates + + +
+
+ ); +} diff --git a/apps/web/cytoscape-dagre.d.ts b/apps/web/cytoscape-dagre.d.ts new file mode 100644 index 0000000..ef9fccc --- /dev/null +++ b/apps/web/cytoscape-dagre.d.ts @@ -0,0 +1,4 @@ +declare module "cytoscape-dagre" { + const ext: cytoscape.Ext; + export = ext; +} diff --git a/scripts/deploy_vast.sh b/scripts/deploy_vast.sh new file mode 100755 index 0000000..13ae5df --- /dev/null +++ b/scripts/deploy_vast.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# scripts/deploy_vast.sh — Push code updates to a running vast.ai instance +# +# Usage: +# bash scripts/deploy_vast.sh [BATCH_WORKERS] +# +# Examples: +# bash scripts/deploy_vast.sh 173.206.147.184 41624 +# bash scripts/deploy_vast.sh 173.206.147.184 41624 2 +# +# What it does: +# 1. rsync code to /root/PROTEA (excludes venvs, node_modules, logs, local config) +# 2. poetry install --without dev (only if pyproject.toml changed) +# 3. npm install (only if package.json changed) +# 4. alembic upgrade head +# 5. restart the full PROTEA stack + +set -euo pipefail + +IP="${1:?Usage: deploy_vast.sh [BATCH_WORKERS]}" +PORT="${2:?Usage: deploy_vast.sh [BATCH_WORKERS]}" +BATCH_WORKERS="${3:-1}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SSH="ssh -p $PORT root@$IP" +GREEN="\033[32m"; YELLOW="\033[33m"; BOLD="\033[1m"; RESET="\033[0m" +step() { printf "\n${BOLD}==> %s${RESET}\n" "$*"; } +ok() { printf " ${GREEN}✓${RESET} %s\n" "$*"; } +warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$*"; } + +# ── 0. Verify SSH connectivity ───────────────────────────────────────────────── +step "Checking SSH connectivity" +if ! $SSH "echo ok" &>/dev/null; then + printf "${BOLD}ERROR${RESET}: Cannot reach root@$IP on port $PORT\n" + printf " Is the instance running? Check: vastai show instances\n" + exit 1 +fi +ok "Connected to $IP:$PORT" + +# ── 1. Sync code ─────────────────────────────────────────────────────────────── +step "Syncing code → /root/PROTEA" + +rsync -az --delete \ + --exclude='.git/' \ + --exclude='__pycache__/' \ + --exclude='*.pyc' \ + --exclude='*.egg-info/' \ + --exclude='.venv/' \ + --exclude='logs/' \ + --exclude='node_modules/' \ + --exclude='.next/' \ + --exclude='storage/' \ + --exclude='protea/config/system.yaml' \ + --exclude='apps/web/.env.local' \ + -e "ssh -p $PORT" \ + "$ROOT/" "root@$IP:/root/PROTEA/" + +ok "Code synced" + +# ── 2. Install Python deps (only if pyproject.toml changed) ─────────────────── +step "Installing Python dependencies" +$SSH "cd /root/PROTEA && export PATH=\$HOME/.local/bin:\$PATH && poetry install --without dev" +ok "Python deps up to date" + +# ── 3. Install frontend deps (only if package.json changed) ─────────────────── +step "Installing frontend dependencies" +$SSH "cd /root/PROTEA/apps/web && npm install --silent" +ok "Frontend deps up to date" + +# ── 4. Run Alembic migrations ────────────────────────────────────────────────── +step "Running database migrations" +$SSH "cd /root/PROTEA && export PATH=\$HOME/.local/bin:\$PATH && poetry run alembic upgrade head" +ok "Schema up to date" + +# ── 5. Restart stack ─────────────────────────────────────────────────────────── +step "Restarting PROTEA stack ($BATCH_WORKERS batch worker(s))" +$SSH "cd /root/PROTEA && export PATH=\$HOME/.local/bin:\$PATH && bash scripts/manage.sh start $BATCH_WORKERS" +ok "Stack restarted" + +# ── Done ─────────────────────────────────────────────────────────────────────── +FRONTEND_PORT=$($SSH "vastai show instance --raw 2>/dev/null | python3 -c \"import sys,json; p=json.load(sys.stdin).get('ports',{}); print(p.get('3000/tcp',[{'HostPort':'3000'}])[0]['HostPort'])\" 2>/dev/null || echo '3000'") + +printf "\n${BOLD}╔══════════════════════════════════════════════════╗${RESET}\n" +printf "${BOLD}║ PROTEA deployed successfully ║${RESET}\n" +printf "${BOLD}╚══════════════════════════════════════════════════╝${RESET}\n\n" +printf " Logs: $SSH 'bash /root/PROTEA/scripts/manage.sh logs'\n" +printf " Status: $SSH 'bash /root/PROTEA/scripts/manage.sh status'\n\n"