diff --git a/backend/app/features/pace/__init__.py b/backend/app/features/pace/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/features/pace/api.py b/backend/app/features/pace/api.py new file mode 100644 index 00000000..8fe93a73 --- /dev/null +++ b/backend/app/features/pace/api.py @@ -0,0 +1,154 @@ +import json +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException, Query + +from app.common.schemas.base import CamelOutBaseModel + +PACE_BASE_URL = "https://pace.ornl.gov" +PACE_LOOKUP_TIMEOUT_SECONDS = 5.0 +PACE_CACHE_TTL_SECONDS = 300.0 + +_PACE_CACHE_LOCK = threading.Lock() +_PACE_CACHE: dict[str, tuple[float, str | None]] = {} + +router = APIRouter(prefix="/pace", tags=["PACE"]) + + +class PaceResolutionOut(CamelOutBaseModel): + execution_id: str + experiment_id: str | None + + +@router.get( + "/resolve", + response_model=PaceResolutionOut, + responses={ + 200: {"description": "PACE resolution result."}, + 422: {"description": "Validation error."}, + }, +) +def resolve_pace_execution( + execution_id: Annotated[ + str, + Query( + ..., + description="Simulation execution ID to resolve to a PACE experiment ID.", + ), + ], +) -> PaceResolutionOut: + normalized_execution_id = _normalize_execution_id(execution_id) + + return PaceResolutionOut( + execution_id=normalized_execution_id, + experiment_id=_resolve_experiment_id(normalized_execution_id), + ) + + +def _normalize_execution_id(execution_id: str) -> str: + normalized_execution_id = execution_id.strip() + if not normalized_execution_id: + raise HTTPException(status_code=422, detail="execution_id must not be blank") + + return normalized_execution_id + + +def _resolve_experiment_id(execution_id: str) -> str | None: + cache_hit, cached_experiment_id = _get_cached_experiment_id(execution_id) + if cache_hit: + return cached_experiment_id + + request = urllib.request.Request( + _build_pace_lookup_url(execution_id), + headers={"Accept": "application/json"}, + ) + + try: + with urllib.request.urlopen( + request, timeout=PACE_LOOKUP_TIMEOUT_SECONDS + ) as response: + if response.status != 200: + _set_cached_experiment_id(execution_id, None) + return None + + response_body = response.read().decode("utf-8") + except ( + TimeoutError, + UnicodeDecodeError, + urllib.error.HTTPError, + urllib.error.URLError, + ): + _set_cached_experiment_id(execution_id, None) + return None + + try: + payload = json.loads(response_body) + except json.JSONDecodeError: + experiment_id = _extract_experiment_id(response_body) + else: + experiment_id = _extract_experiment_id(payload) + + _set_cached_experiment_id(execution_id, experiment_id) + return experiment_id + + +def _build_pace_lookup_url(execution_id: str) -> str: + encoded_execution_id = urllib.parse.quote(execution_id, safe="") + return f"{PACE_BASE_URL}/ajax/specificSearch/lid:{encoded_execution_id}/expid" + + +def _extract_experiment_id(payload: Any) -> str | None: + direct_experiment_id = _normalize_experiment_id(payload) + if direct_experiment_id is not None: + return direct_experiment_id + + if not isinstance(payload, list) or not payload: + return None + + first_item = payload[0] + if not isinstance(first_item, dict): + return None + + return _normalize_experiment_id(first_item.get("expid")) + + +def _normalize_experiment_id(value: Any) -> str | None: + if isinstance(value, int): + return str(value) + + if not isinstance(value, str): + return None + + normalized_experiment_id = value.strip() + if not normalized_experiment_id or not normalized_experiment_id.isdigit(): + return None + + return normalized_experiment_id + + +def _get_cached_experiment_id(execution_id: str) -> tuple[bool, str | None]: + now = time.monotonic() + with _PACE_CACHE_LOCK: + cached_entry = _PACE_CACHE.get(execution_id) + if cached_entry is None: + return False, None + + expires_at, experiment_id = cached_entry + if expires_at <= now: + _PACE_CACHE.pop(execution_id, None) + return False, None + + return True, experiment_id + + +def _set_cached_experiment_id(execution_id: str, experiment_id: str | None) -> None: + with _PACE_CACHE_LOCK: + _PACE_CACHE[execution_id] = ( + time.monotonic() + PACE_CACHE_TTL_SECONDS, + experiment_id, + ) diff --git a/backend/app/main.py b/backend/app/main.py index ab549f63..e6c9ea41 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.core.logger import _setup_root_logger from app.features.ingestion.api import router as ingestion_router from app.features.machine.api import router as machine_router +from app.features.pace.api import router as pace_router from app.features.simulation.api import case_router, simulation_router from app.features.user.api.oauth import auth_router, user_router from app.features.user.api.token import router as token_router @@ -36,6 +37,7 @@ def create_app() -> FastAPI: app.include_router(simulation_router, prefix=API_BASE) app.include_router(case_router, prefix=API_BASE) app.include_router(machine_router, prefix=API_BASE) + app.include_router(pace_router, prefix=API_BASE) app.include_router(user_router, prefix=API_BASE) app.include_router(auth_router, prefix=API_BASE) app.include_router(token_router, prefix=API_BASE) diff --git a/backend/tests/features/pace/test_api.py b/backend/tests/features/pace/test_api.py new file mode 100644 index 00000000..56a32698 --- /dev/null +++ b/backend/tests/features/pace/test_api.py @@ -0,0 +1,301 @@ +import json +import urllib.error +import urllib.request +from email.message import Message +from urllib.parse import urlparse + +import pytest + +from app.api.version import API_BASE +from app.features.pace import api as pace_api + + +class _FakeHttpResponse: + def __init__(self, status: int, body: str) -> None: + self.status = status + self._body = body.encode("utf-8") + + def read(self) -> bytes: + return self._body + + def __enter__(self) -> "_FakeHttpResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +class _FakeHttpError(urllib.error.HTTPError): + def __init__(self, url: str, code: int, msg: str, body: bytes) -> None: + super().__init__(url, code, msg, hdrs=Message(), fp=None) + self._body = body + + def read(self, amt: int = -1) -> bytes: + return self._body if amt == -1 else self._body[:amt] + + +class TestResolvePaceExecution: + @pytest.fixture(autouse=True) + def clear_cache(self) -> None: + pace_api._PACE_CACHE.clear() + + def test_endpoint_returns_experiment_id_on_success( + self, client, monkeypatch + ) -> None: + captured_request: list[urllib.request.Request] = [] + execution_id = "52448807.260505-035011" + + def fake_urlopen(request: urllib.request.Request, timeout: float): + captured_request.append(request) + assert timeout == 5.0 + return _FakeHttpResponse(200, json.dumps([{"expid": "228920"}])) + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": execution_id} + ) + + assert response.status_code == 200 + assert response.json() == { + "executionId": execution_id, + "experimentId": "228920", + } + assert captured_request[0].full_url == ( + "https://pace.ornl.gov/ajax/specificSearch/lid:52448807.260505-035011/expid" + ) + + def test_endpoint_returns_experiment_id_on_direct_numeric_payload( + self, client, monkeypatch + ) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: _FakeHttpResponse(200, "214043"), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": "214043"} + + def test_endpoint_encodes_only_execution_id_portion( + self, client, monkeypatch + ) -> None: + captured_request: list[urllib.request.Request] = [] + execution_id = "lid/ 42?next=1" + + def fake_urlopen(request: urllib.request.Request, timeout: float): + captured_request.append(request) + return _FakeHttpResponse(200, json.dumps([{"expid": "228920"}])) + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": execution_id} + ) + + assert response.status_code == 200 + assert captured_request[0].full_url == ( + "https://pace.ornl.gov/ajax/specificSearch/lid:lid%2F%2042%3Fnext%3D1/expid" + ) + + def test_endpoint_uses_fixed_pace_host(self, client, monkeypatch) -> None: + captured_request: list[urllib.request.Request] = [] + execution_id = "https://evil.example/path?q=1" + + def fake_urlopen(request: urllib.request.Request, timeout: float): + captured_request.append(request) + return _FakeHttpResponse(200, json.dumps([{"expid": "228920"}])) + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": execution_id} + ) + + assert response.status_code == 200 + parsed_url = urlparse(captured_request[0].full_url) + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "pace.ornl.gov" + assert ( + parsed_url.path + == "/ajax/specificSearch/lid:https%3A%2F%2Fevil.example%2Fpath%3Fq%3D1/expid" + ) + + def test_endpoint_returns_null_on_timeout(self, client, monkeypatch) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError()), + ) + + response = client.get( + f"{API_BASE}/pace/resolve", + params={"execution_id": "52448807.260505-035011"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "executionId": "52448807.260505-035011", + "experimentId": None, + } + + def test_endpoint_returns_null_on_upstream_non_200( + self, client, monkeypatch + ) -> None: + request = urllib.request.Request( + "https://pace.ornl.gov/ajax/specificSearch/lid:x/expid" + ) + error = _FakeHttpError( + request.full_url, 503, "Service Unavailable", b"retry later" + ) + + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: (_ for _ in ()).throw(error), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": None} + + def test_endpoint_returns_null_on_malformed_json(self, client, monkeypatch) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: _FakeHttpResponse(200, "{not-json"), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": None} + + def test_endpoint_returns_null_on_empty_array(self, client, monkeypatch) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: _FakeHttpResponse(200, "[]"), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": None} + + def test_endpoint_returns_null_when_expid_is_missing( + self, client, monkeypatch + ) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: _FakeHttpResponse(200, json.dumps([{}])), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": None} + + def test_endpoint_returns_null_when_expid_is_blank( + self, client, monkeypatch + ) -> None: + monkeypatch.setattr( + pace_api.urllib.request, + "urlopen", + lambda *args, **kwargs: _FakeHttpResponse( + 200, json.dumps([{"expid": " "}]) + ), + ) + + response = client.get(f"{API_BASE}/pace/resolve", params={"execution_id": "x"}) + + assert response.status_code == 200 + assert response.json() == {"executionId": "x", "experimentId": None} + + def test_endpoint_caches_successful_resolutions(self, client, monkeypatch) -> None: + call_count = 0 + + def fake_urlopen(request: urllib.request.Request, timeout: float): + nonlocal call_count + call_count += 1 + return _FakeHttpResponse(200, json.dumps([{"expid": "228920"}])) + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + first_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-exec"} + ) + second_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-exec"} + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert first_response.json()["experimentId"] == "228920" + assert second_response.json()["experimentId"] == "228920" + assert call_count == 1 + + def test_endpoint_caches_unresolved_resolutions(self, client, monkeypatch) -> None: + call_count = 0 + + def fake_urlopen(request: urllib.request.Request, timeout: float): + nonlocal call_count + call_count += 1 + return _FakeHttpResponse(200, "[]") + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + first_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-miss"} + ) + second_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-miss"} + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert first_response.json()["experimentId"] is None + assert second_response.json()["experimentId"] is None + assert call_count == 1 + + def test_endpoint_caches_timeouts(self, client, monkeypatch) -> None: + call_count = 0 + + def fake_urlopen(request: urllib.request.Request, timeout: float): + nonlocal call_count + call_count += 1 + raise TimeoutError() + + monkeypatch.setattr(pace_api.urllib.request, "urlopen", fake_urlopen) + + first_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-timeout"} + ) + second_response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": "cached-timeout"} + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert first_response.json()["experimentId"] is None + assert second_response.json()["experimentId"] is None + assert call_count == 1 + + def test_endpoint_rejects_missing_execution_id(self, client) -> None: + response = client.get(f"{API_BASE}/pace/resolve") + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "execution_id"] + + @pytest.mark.parametrize("value", ["", " "]) + def test_endpoint_rejects_blank_execution_id(self, client, value: str) -> None: + response = client.get( + f"{API_BASE}/pace/resolve", params={"execution_id": value} + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "execution_id must not be blank"} diff --git a/frontend/src/features/simulations/SimulationDetailsPage.tsx b/frontend/src/features/simulations/SimulationDetailsPage.tsx index 2edd92d0..af101dba 100644 --- a/frontend/src/features/simulations/SimulationDetailsPage.tsx +++ b/frontend/src/features/simulations/SimulationDetailsPage.tsx @@ -1,5 +1,7 @@ +import { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { resolvePaceExecution } from '@/features/simulations/api/api'; import { SimulationDetailsView } from '@/features/simulations/components/SimulationDetailsView'; import { useSimulation } from '@/features/simulations/hooks/useSimulation'; @@ -7,6 +9,9 @@ export const SimulationDetailsPage = () => { const { id } = useParams<{ id: string }>(); const location = useLocation(); const { data: simulation, loading, error } = useSimulation(id ?? ''); + const [paceExperimentId, setPaceExperimentId] = useState(null); + const [isResolvingPace, setIsResolvingPace] = useState(false); + const [paceResolutionAttempted, setPaceResolutionAttempted] = useState(false); const state = location.state as { from?: string } | null; const backHref = typeof state?.from === 'string' ? state.from : '/browse'; @@ -18,6 +23,44 @@ export const SimulationDetailsPage = () => { : normalizedBackHref.startsWith('/simulations') ? 'Back to Simulations' : 'Back to Runs'; + const currentSimulation = simulation?.id === id ? simulation : null; + const executionId = currentSimulation?.executionId?.trim() ?? ''; + + useEffect(() => { + if (!executionId) { + setPaceExperimentId(null); + setIsResolvingPace(false); + setPaceResolutionAttempted(false); + return; + } + + let cancelled = false; + setPaceExperimentId(null); + setIsResolvingPace(true); + setPaceResolutionAttempted(false); + + resolvePaceExecution(executionId) + .then((result) => { + if (!cancelled) { + setPaceExperimentId(result.experimentId?.trim() || null); + } + }) + .catch(() => { + if (!cancelled) { + setPaceExperimentId(null); + } + }) + .finally(() => { + if (!cancelled) { + setIsResolvingPace(false); + setPaceResolutionAttempted(true); + } + }); + + return () => { + cancelled = true; + }; + }, [executionId]); if (!id) { return ( @@ -27,7 +70,7 @@ export const SimulationDetailsPage = () => { ); } - if (loading) { + if (loading || (simulation !== null && currentSimulation === null)) { return (
Loading simulation details…
@@ -43,7 +86,7 @@ export const SimulationDetailsPage = () => { ); } - if (!simulation) { + if (!currentSimulation) { return (
Simulation not found
@@ -51,7 +94,26 @@ export const SimulationDetailsPage = () => { ); } + const paceLink = executionId + ? paceExperimentId + ? { + href: `https://pace.ornl.gov/exp-details/${encodeURIComponent(paceExperimentId)}`, + label: 'Open in PACE', + } + : { + href: `https://pace.ornl.gov/search/${encodeURIComponent(executionId)}`, + label: 'Search in PACE', + } + : null; + return ( - + ); }; diff --git a/frontend/src/features/simulations/api/api.ts b/frontend/src/features/simulations/api/api.ts index 44757403..4239c6ea 100644 --- a/frontend/src/features/simulations/api/api.ts +++ b/frontend/src/features/simulations/api/api.ts @@ -3,6 +3,12 @@ import type { CaseOut, SimulationCreate, SimulationOut } from '@/types'; export const SIMULATIONS_URL = '/simulations'; export const CASES_URL = '/cases'; +export const PACE_URL = '/pace'; + +export interface PaceResolutionOut { + executionId: string; + experimentId: string | null; +} export const createSimulation = async (data: SimulationCreate): Promise => { const res = await api.post(SIMULATIONS_URL, data); @@ -26,6 +32,15 @@ export const getSimulationById = async (id: string): Promise => { return res.data; }; +export const resolvePaceExecution = async (executionId: string): Promise => { + const res = await api.get(`${PACE_URL}/resolve`, { + headers: { 'Cache-Control': 'no-cache' }, + params: { execution_id: executionId }, + }); + + return res.data; +}; + export const listCases = async (url: string = CASES_URL): Promise => { const res = await api.get(url, { headers: { 'Cache-Control': 'no-cache' }, diff --git a/frontend/src/features/simulations/components/SimulationDetailsView.tsx b/frontend/src/features/simulations/components/SimulationDetailsView.tsx index 48205f4d..4a9ad40c 100644 --- a/frontend/src/features/simulations/components/SimulationDetailsView.tsx +++ b/frontend/src/features/simulations/components/SimulationDetailsView.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, CircleHelp } from 'lucide-react'; import { useState } from 'react'; import { Link } from 'react-router-dom'; @@ -9,8 +9,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; +import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { SimulationPathCard } from '@/features/simulations/components/SimulationPathCard'; import { SimulationTypeBadge } from '@/features/simulations/components/SimulationTypeBadge'; import { cn } from '@/lib/utils'; @@ -23,6 +25,12 @@ interface SimulationDetailsViewProps { canEdit?: boolean; backHref?: string; backLabel?: string; + paceLink?: { + href: string; + label: string; + } | null; + isResolvingPace?: boolean; + showPaceFallbackInfo?: boolean; } // -------------------- Small UI helpers -------------------- @@ -52,9 +60,13 @@ export const SimulationDetailsView = ({ canEdit = false, backHref = '/browse', backLabel = 'Back to Runs', + paceLink = null, + isResolvingPace = false, + showPaceFallbackInfo = false, }: SimulationDetailsViewProps) => { const [activeTab, setActiveTab] = useState('summary'); const [notes, setNotes] = useState(simulation.notesMarkdown || ''); + const performanceLinks = simulation.groupedLinks.performance ?? []; // Temporary local-only comments const [newComment, setNewComment] = useState(''); @@ -428,9 +440,9 @@ export const SimulationDetailsView = ({
- {simulation.groupedLinks.performance?.length ? ( + {performanceLinks.length || paceLink ? (
    - {simulation.groupedLinks.performance.map((p) => ( + {performanceLinks.map((p) => (
  • ))} + {paceLink && ( +
  • + + + + + {paceLink.label} + + {isResolvingPace && ( + <> + + + + + + + + Checking for a direct PACE experiment link + + + + + )} + {!isResolvingPace && showPaceFallbackInfo && ( + + + + + + + Direct PACE experiment link not found. Search results may + still contain this run. + + + + )} +
  • + )}
) : (