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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The app accepts analysis jobs via HTTP or the client and stores results in memor

from heliotrapi.client import AnalysisClient

client = AnalysisClient("ixx-analysis.diamond.ac.uk")
client = AnalysisClient("https://ixx-analysis.diamond.ac.uk")

print(client.available_analyses()) #see available analyses

Expand All @@ -62,6 +62,31 @@ The app accepts analysis jobs via HTTP or the client and stores results in memor

```

## Sending/Recieving results as an http request

submit jobs to `/analyse` as a json blob via an HTTP POST request

if want to call the function "double" eg:

```python
def double(number: float | int) -> float:
"""Example analysis that doubles a number."""
return number * 2
```

then the json would be sent as a POST request like:

{"analysis_name": "double", "inputs": {"number": 5}}

the server will handle request_id time and created_at,
but if you want you can also send it in it's full form
and create a uuid and timestamp yourself:

{"analysis_name":"double","inputs":{"number":5},"request_id":"d68de927-79f5-4df3-83d9-d125445c758a","created_at":"2026-05-29T11:47:09.087317"}

you get then return the last results from `/result/latest` as a GET request


## Using the WebUI

You can also navigate to the url or the ip address to be met with:
Expand Down
14 changes: 14 additions & 0 deletions src/heliotrapi/analyses/simple_maths.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,25 @@

@analysis()
def double(number: float | int) -> float:
"""Example analysis that doubles a number."""

return number * 2


@analysis()
def sum_numbers(numbers: Sequence[float | int]) -> float:
"""Example analysis that sums a sequence of numbers."""

return np.sum(numbers)


@analysis()
def sine_wave(array: np.ndarray | None) -> np.ndarray:
"""Example analysis that returns a sine wave for a given array,
or a default sine wave 0->2pi if no array is provided."""
if array is None:
return np.linspace(0, 2 * np.pi, 100)

sine = np.sin(array)

return sine
22 changes: 13 additions & 9 deletions src/heliotrapi/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ async def health():
return {"status": "ok"}


def annotation_to_str(annotation) -> str:
"""Convert a Python annotation to a clean, readable string."""
if annotation is inspect.Parameter.empty:
return "Any"
# Prefer __name__ for plain types (float, int, str, bool, ndarray, ...)
if hasattr(annotation, "__name__"):
return annotation.__name__
# Fallback for generics like list[float], Optional[str], etc.
return str(annotation)


@ROUTER.get(ANALYSES_ROUTE)
async def available_analyses() -> list[dict[str, Any]]:
analyses_info = []
Expand All @@ -39,21 +50,14 @@ async def available_analyses() -> list[dict[str, Any]]:
"default": repr(p.default)
if p.default != inspect.Parameter.empty
else None,
"annotation": str(p.annotation)
if p.annotation != inspect.Parameter.empty
else "Any",
"annotation": annotation_to_str(p.annotation),
}
)
annotations = (
str(sig.return_annotation)
if sig.return_annotation != inspect.Signature.empty
else "Any"
)
analyses_info.append(
{
"name": name,
"parameters": params,
"annotations": annotations,
"annotations": annotation_to_str(sig.return_annotation),
"docstring": func.__doc__ or "",
}
)
Expand Down
98 changes: 86 additions & 12 deletions src/heliotrapi/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class AnalysisUI {

// Load analyses
await this.loadAnalyses();
this.setupTooltip();

// Try to load all jobs from backend if allowed
let loadedFromBackend = false;
Expand Down Expand Up @@ -135,6 +136,43 @@ class AnalysisUI {
}
}

setupTooltip() {
const tooltip = document.getElementById('tooltip');

document.addEventListener('mouseover', (e) => {
const icon = e.target.closest('.info-icon');
if (!icon) return;

const text = decodeURIComponent(icon.dataset.doc || '');

tooltip.textContent = text || 'No description available';
tooltip.style.display = 'block';
});

document.addEventListener('mousemove', (e) => {
const icon = e.target.closest('.info-icon');

if (!icon) {
tooltip.style.display = 'none';
return;
}

tooltip.style.left = e.pageX + 12 + 'px';
tooltip.style.top = e.pageY + 12 + 'px';
});

document.addEventListener('mouseout', (e) => {
if (e.target.closest('.info-icon')) {
tooltip.style.display = 'none';
}
});
}

// Helper: resolve annotation from a parameter object, with fallbacks
getAnnotation(param) {
return param.annotation || param.type || param.type_hint || param.kind || 'Any';
}

renderAnalysesList() {
const list = document.getElementById('analyses-list');

Expand All @@ -155,12 +193,20 @@ class AnalysisUI {
}

const paramsText = analysis.parameters
.map(p => `${p.name}: ${p.annotation}`)
.map(p => `${p.name}: ${this.getAnnotation(p)}`)
.join(', ');

item.innerHTML = `
<div class="analysis-item-name">${analysis.name}</div>
<div class="analysis-item-params">${paramsText || 'No parameters'}</div>
<div class="analysis-item-name">
${analysis.name}
<span class="info-icon"
data-doc="${encodeURIComponent(analysis.docstring || '')}">
i
</span>
</div>
<div class="analysis-item-params">
${paramsText || 'No parameters'}
</div>
`;

item.addEventListener('click', () => this.selectAnalysis(analysis));
Expand Down Expand Up @@ -203,7 +249,9 @@ class AnalysisUI {
const label = document.createElement('label');
label.textContent = param.name;

const inputType = this.getUIInputType(param.annotation);
// FIX: use getAnnotation() so type hints always resolve
const annotation = this.getAnnotation(param);
const inputType = this.getUIInputType(annotation);

let input;

Expand Down Expand Up @@ -241,18 +289,34 @@ class AnalysisUI {

input.type = inputType;

input.placeholder = param.default
? `Default: ${param.default}`
: `Enter ${param.name}`;
input.placeholder = `Enter ${param.name}`;
}

// Pre-populate with default value if present
if (param.default !== null && param.default !== undefined) {
if (inputType === 'checkbox') {
input.checked = param.default === 'True';
} else if (param.default !== 'None') {
// Backend sends repr() strings, so strip surrounding quotes
// e.g. "'kev'" → "kev", "0.5" → "0.5"
const raw = String(param.default);
const unquoted = /^(['"]).*\1$/.test(raw)
? raw.slice(1, -1)
: raw;
input.value = unquoted;
}
// 'None' default: leave field empty — null is sent if user doesn't fill it in
}

input.id = `param-${param.name}`;
input.dataset.type = param.annotation;
// FIX: store resolved annotation, not raw (possibly undefined) field
input.dataset.type = annotation;

const typeHint = document.createElement('div');

typeHint.className = 'parameter-type';
typeHint.textContent = `Type: ${param.annotation}`;
// FIX: display the resolved annotation
typeHint.textContent = `Type: ${annotation}`;

group.appendChild(label);
group.appendChild(input);
Expand Down Expand Up @@ -373,16 +437,26 @@ class AnalysisUI {

let value;

const ann = param.annotation.toLowerCase();
// FIX: use getAnnotation() so type-coercion logic always has a valid string
const ann = this.getAnnotation(param).toLowerCase();

// A parameter is optional if its annotation includes 'none' (e.g. "ndarray | None",
// "Optional[float]") or if its default is explicitly 'None'
const isOptional = ann.includes('none') || param.default === 'None';

// Checkbox
if (input.type === 'checkbox') {
value = input.checked;
} else {
value = input.value;
value = input.value.trim();
}

if (!value && value !== false && value !== 0) {
// If the user explicitly typed "None", or the field is empty and optional → send null
if (value === 'None' || value === 'none' || (!value && value !== false && value !== 0)) {
if (isOptional || value === 'None' || value === 'none') {
inputs[param.name] = null;
continue;
}
this.showError(`Please fill in ${param.name}`);
return null;
}
Expand Down
1 change: 1 addition & 0 deletions src/heliotrapi/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ <h2>Results & History</h2>
</div>

<script src="/ui/app.js"></script>
<div id="tooltip" class="tooltip"></div>
</body>

</html>
40 changes: 40 additions & 0 deletions src/heliotrapi/ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,43 @@ button {
::-webkit-scrollbar-thumb:hover {
background: #764ba2;
}


/* ===== Info icon next to analysis name ===== */
.info-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
margin-left: 8px;
border-radius: 50%;
background: #667eea;
color: white;
font-size: 11px;
font-style: normal;
cursor: pointer;
user-select: none;
vertical-align: middle;
position: relative;
}

.info-icon:hover {
background: #764ba2;
}

/* ===== Tooltip ===== */
.tooltip {
position: absolute;
display: none;
max-width: 320px;
padding: 10px 12px;
background: rgba(20, 20, 20, 0.95);
color: white;
font-size: 12px;
border-radius: 8px;
z-index: 9999;
white-space: pre-wrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
Loading
Loading