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
65 changes: 65 additions & 0 deletions FRONTEND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Web Frontend for Indigo Analysis API

The API includes a web frontend hosted at the root path (`/`).

## Features

- **Analysis Discovery**: Browse all available analyses registered in the system
- **Dynamic Form Generation**: Automatically generates input forms based on analysis parameters
- **Real-time Results**: Submits jobs to the queue and polls for results
- **Result History**: Tracks all submitted jobs and their results (stored in browser's localStorage)
- **Type-Aware Inputs**: Automatically converts form inputs to appropriate types (int, float, bool, list)
- **Beautiful UI**: Modern gradient design with smooth animations and transitions

## Accessing the Frontend

Once the API server is running:

```bash
indigoapi serve
```

Open your browser and navigate to:
```
http://localhost:8000
```

## How to Use

1. **Select an Analysis**: Click on any analysis in the left panel to select it
2. **Fill in Parameters**: The form on the right will dynamically populate with the analysis parameters
3. **Submit**: Click "Submit Analysis" to queue the job
4. **View Results**: Results appear in the bottom panel and update in real-time
5. **Track History**: All submitted jobs are displayed with their status and results


### Frontend Files

- **`templates/index.html`** - Main HTML template
- **`static/app.js`** - API client and UI logic
- **`static/style.css`** - Responsive styling with gradient design

### Backend Integration

The frontend communicates with the following API endpoints:

- `GET /get_analyses` - Fetch list of available analyses with parameters
- `POST /analyse` - Submit a new analysis job
- `GET /result/latest` - Get the most recent result
- `GET /result/id/{request_id}` - Get result by request ID
- `GET /health` - Check API availability
- `GET /endpoints` - Get all available endpoints


## Example Workflow

1. Server starts with analyses registered (e.g., "double", "sum_numbers")
2. User opens frontend at http://localhost:8000
3. User selects "double" analysis from the list
4. Form shows parameter "number" with type hint
5. User enters "5" and clicks "Submit Analysis"
6. Request ID is displayed in a success message
7. Results panel shows the job with "running" status
8. Frontend polls for updates every 2 seconds
9. When complete, status changes and result is displayed
10. History is persisted automatically
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ The app accepts analysis jobs via HTTP or the client and stores results in memor

```

## Using the WebUI

You can also navigate to the url or the ip address to be met with:

![Web UI](images/webui.png)

### Request flow

Expand Down
5 changes: 4 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ plugins:
- ./plugins # local folder for plugins
github_repos: # list of Https GitHub repos with analysis code (ending with .git)
- https://github.com/DiamondLightSource/xrpd-toolbox.git
register_all: False # whether to register all analyses found in plugins or only those decorated
register_all: True # whether to register all analyses found in plugins or only those decorated
rabbitmq:
enabled: True
host: "i15-1-rabbitmq-daq.diamond.ac.uk"
Expand All @@ -28,3 +28,6 @@ rabbitmq:
- "/topic/gda.messages.scan" # gda scans
- "/topic/gda.messages.processing" # dawn stuff
- "/topic/public.analysis.trigger" # custom topic for triggering analyses from other services

webui:
hide_non_webui_jobs: false # If true, only jobs submitted via the web UI are shown
3 changes: 3 additions & 0 deletions helm/indigoapi/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ resources:
memory: 4Gi

livenessProbe:

webui:
showAllJobsInWebui: false # Set to true to show all jobs in the web UI, not just those submitted via the web UI
httpGet:
path: /healthz
port: http
Expand Down
Binary file added images/webui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/indigoapi/analyses/delay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import time

from indigoapi.analysis_core.decorator import analysis


@analysis()
def delay(seconds: float):
"""Simulate a long-running analysis by sleeping for specified number of seconds."""

time.sleep(seconds)
return f"Slept for {seconds} seconds"
2 changes: 1 addition & 1 deletion src/indigoapi/analyses/peak_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def gaussian(x: np.ndarray, amplitude: float, x0: float, sigma: float) -> np.nda


@analysis()
def gaussian_fit(x: list[int | float], y: list):
def gaussian_fit(x: list[int | float], y: list[int | float]):
"""
data = {
"x": [...],
Expand Down
28 changes: 20 additions & 8 deletions src/indigoapi/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.routing import APIRoute

from indigoapi.analysis_core.registry import get_analysis, list_analyses
from indigoapi.models import AnalysisRequest, AnalysisResult
from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
from indigoapi.task_queue import QueueManager

ROUTER = APIRouter()
Expand All @@ -17,6 +17,7 @@
RESULT_LATEST_ROUTE = "/result/latest"
RESULT_BY_ID_ROUTE = "/result/id/{request_id}"
ENDPOINTS_ROUTE = "/endpoints"
RESULTS_ALL_ROUTE = "/results/all"


@ROUTER.get(HEALTH_ROUTE)
Expand All @@ -43,7 +44,7 @@ async def available_analyses() -> list[dict[str, Any]]:
else "Any",
}
)
return_annotation = (
annotations = (
str(sig.return_annotation)
if sig.return_annotation != inspect.Signature.empty
else "Any"
Expand All @@ -52,21 +53,22 @@ async def available_analyses() -> list[dict[str, Any]]:
{
"name": name,
"parameters": params,
"return_annotation": return_annotation,
"annotations": annotations,
"docstring": func.__doc__ or "",
}
)
return analyses_info


@ROUTER.post(ANALYSE_ROUTE)
async def analyse(request: Request, job: AnalysisRequest):
async def analyse(request: Request, job: AnalysisRequest) -> AnalysisResponse:
queue: QueueManager = request.app.state.queue_manager
await queue.enqueue(job)
return {"request_id": job.request_id}
analysis_response = await queue.enqueue(job)
return analysis_response


@ROUTER.get(RESULT_LATEST_ROUTE, response_model=AnalysisResult)
async def get_latest_result(request: Request):
async def get_latest_result(request: Request) -> AnalysisResult:

queue_manager = request.app.state.queue_manager

Expand All @@ -81,7 +83,7 @@ async def result(request: Request, request_id: UUID):
queue: QueueManager = request.app.state.queue_manager
if request_id not in queue.results:
raise HTTPException(404, "Result not found")
result, duration = queue.results[request_id]
result = queue.results[request_id]
return result


Expand All @@ -96,3 +98,13 @@ async def get_endpoints():
for route in ROUTER.routes
if isinstance(route, APIRoute)
]


# New endpoint to return all jobs/results if enabled in config
@ROUTER.get(RESULTS_ALL_ROUTE)
async def get_all_results(request: Request):
queue: QueueManager = request.app.state.queue_manager
# Return all jobs (pending, running, completed, failed), sorted by created_at
results = list(queue.results.values())
results.sort(key=lambda r: getattr(r, "created_at", None) or 0, reverse=True)
return results
84 changes: 36 additions & 48 deletions src/indigoapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import Any
from uuid import UUID

import numpy as np
import requests

from indigoapi.api.routes import (
Expand All @@ -15,8 +14,10 @@
HEALTH_ROUTE,
RESULT_BY_ID_ROUTE,
RESULT_LATEST_ROUTE,
RESULTS_ALL_ROUTE,
)
from indigoapi.models import AnalysisRequest, AnalysisResult
from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
from indigoapi.utils.serialisers import serialise

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
Expand All @@ -33,13 +34,10 @@ def __init__(
session: requests.Session | None = None,
):
self.base_url = base_url.rstrip("/")

self.base_url = base_url.rstrip("/")

self.latest_request_id: UUID | None = None
self.session = session or requests.Session()

def list_analyses(
def available_analyses(
self, as_strings: bool = True
) -> list[dict[str, Any]] | list[str]:
resp = self.session.get(f"{self.base_url}{ANALYSES_ROUTE}")
Expand All @@ -57,16 +55,14 @@ def _format_analysis_signature(self, analysis: dict[str, Any]) -> str:
param_str += f" = {param['default']}"
params.append(param_str)

return_annotation = analysis.get("return_annotation", "Any")
annotations = analysis.get("annotations", "Any")
if params:
params_block = ",\n ".join(params)
signature = (
f"{analysis['name']}(\n"
f" {params_block},\n"
f" ) -> {return_annotation}:"
f"{analysis['name']}(\n {params_block},\n ) -> {annotations}:"
)
else:
signature = f"{analysis['name']}() -> {return_annotation}:"
signature = f"{analysis['name']}() -> {annotations}:"

return signature

Expand All @@ -75,25 +71,6 @@ def health(self) -> dict[str, Any]:
resp.raise_for_status()
return resp.json()

def _convert_to_serialisable(self, obj: Any) -> Any:

if isinstance(obj, np.ndarray):
return obj.tolist()

if isinstance(obj, np.integer):
return int(obj)

if isinstance(obj, np.floating):
return float(obj)

if isinstance(obj, dict):
return {k: self._convert_to_serialisable(v) for k, v in obj.items()}

if isinstance(obj, (list, tuple, set)):
return [self._convert_to_serialisable(v) for v in obj]

return obj

def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
"""
Submit an analysis job.
Expand All @@ -102,7 +79,7 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
client.submit("gaussian_fit", x=x, y=y)
"""

inputs = self._convert_to_serialisable(inputs)
inputs = serialise(inputs)

analysis_name = (
analysis.__name__ if isinstance(analysis, Callable) else analysis
Expand All @@ -113,7 +90,10 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:

resp = self.session.post(f"{self.base_url}{ANALYSE_ROUTE}", json=json)

resp.raise_for_status()
resp.raise_for_status() # raise for 404 or other non-200 errors

analysis_response = AnalysisResponse.model_validate(resp.json())
analysis_response.is_accepted() # will raise if not accepted

request_id = UUID(resp.json()["request_id"])
self.latest_request_id = request_id
Expand All @@ -123,17 +103,15 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
def request_result(self, request_id: UUID) -> AnalysisResult | None:

route = RESULT_BY_ID_ROUTE.format(request_id=request_id)

resp = self.session.get(f"{self.base_url}{route}")

if resp.status_code == 404:
return None

resp.raise_for_status()

response = resp.json()

return AnalysisResult(**response)
return AnalysisResult.model_validate(response)

def get_result(
self,
Expand All @@ -157,6 +135,7 @@ def get_result(
return AnalysisResult(
status="error",
analysis_name="",
inputs={},
result=None,
created_at=datetime.now(),
finished_at=datetime.now(),
Expand All @@ -172,6 +151,7 @@ def get_last_submitted_result(
return AnalysisResult(
status="error",
analysis_name="",
inputs={},
result=None,
created_at=datetime.now(),
finished_at=datetime.now(),
Expand All @@ -188,6 +168,11 @@ def get_endpoints(self):
resp.raise_for_status()
return resp.json()

def get_all_results(self):
resp = self.session.get(f"{self.base_url}{RESULTS_ALL_ROUTE}")
resp.raise_for_status()
return resp.json()

def get_request_id_result(
self,
request_id: UUID,
Expand All @@ -209,23 +194,26 @@ def get_request_id_result(
time.sleep(poll_interval)


if __name__ == "__main__":
import numpy as np

from indigoapi.analyses.peak_fitting import gaussian
# if __name__ == "__main__":
# from indigoapi.analyses.peak_fitting import gaussian

x = np.linspace(0, 20, 200)
# x = np.round(np.linspace(0, 20, 50), 3)
# y = np.round(gaussian(x, 10, 5, 1) + (np.random.rand(x.shape[-1]) / 5), 3)

y = gaussian(x, 10, 5, 1) + (np.random.rand(x.shape[-1]) / 5)
# client = AnalysisClient()

client = AnalysisClient()
# for i in range(5):
# id = client.submit("double", number=i)
# result = client.get_request_id_result(id)
# client.submit("gaussian_fit", x=x, y=y)

# client.submit(gaussian_fit.__name__, x=x, y=y)
# for i in client.get_all_results():
# print(i, "\n")

client.submit("beam_energy_to_wavelength", beam_energy=15)
# available_analyses = client.available_analyses(as_strings=True)

print(client.get_result())
# for analysis in available_analyses:
# print(analysis)

# print(client.get_endpoints())
# for i in client.list_analyses()[0:4]:
# print(i)
# client.submit("gaussian_fit", num=x, y=y)
# print(client.get_result())
Loading
Loading