Skip to content
Draft
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
127 changes: 115 additions & 12 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import logging
import urllib.parse
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Annotated
from dataclasses import dataclass
from typing import Annotated, Any

import jwt
from fastapi import (
Expand All @@ -11,22 +13,29 @@
Body,
Depends,
FastAPI,
Form,
HTTPException,
Request,
Response,
status,
)
from fastapi.datastructures import Address
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.responses import (
FileResponse,
HTMLResponse,
RedirectResponse,
StreamingResponse,
)
from fastapi.templating import Jinja2Templates
from observability_utils.tracing import (
add_span_attributes,
get_tracer,
start_as_current_span,
)
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.trace import get_tracer_provider
from pydantic import ValidationError
from pydantic import Field, ValidationError
from pydantic.json_schema import SkipJsonSchema
from starlette.responses import JSONResponse
from super_state_machine.errors import TransitionError
Expand All @@ -38,6 +47,7 @@
ObservabilityContextPropagator,
VersionHeaders,
)
from blueapi.utils.base_model import BlueapiBaseModel
from blueapi.worker import TrackableTask, WorkerState
from blueapi.worker.event import TaskStatusEnum

Expand Down Expand Up @@ -163,15 +173,6 @@ async def on_token_error_401(_: Request, __: Exception):
)


@secure_router.get("/", include_in_schema=False)
def root_redirect() -> RedirectResponse:
"""Redirect to docs url"""
return RedirectResponse(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
url=ApplicationConfig.DOCS_ENDPOINT,
)


@secure_router_v1.get("/environment", tags=[Tag.ENV])
@secure_router.get("/environment", tags=[Tag.ENV])
@start_as_current_span(TRACER, "runner")
Expand Down Expand Up @@ -610,3 +611,105 @@ async def log_request_details(
log(log_message, extra=extra)

return response


templates = Jinja2Templates(directory="templates")


@secure_router.get("/", include_in_schema=False, response_class=HTMLResponse)
def root_landing(
request: Request,
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> HTMLResponse:

devices = runner.run(interface.get_devices)
devices = [
{"device": device.name, "protocols": [p.name for p in device.protocols]}
for device in devices
]

plans = runner.run(interface.get_plans)

@dataclass()
class TmpModel:
name: str
description: str | None
parameter_schema: dict[str, Any]

format_plans: list[TmpModel] = []
for plan in plans:
sch: dict[str, Any] = plan.parameter_schema
plan_args: dict[str, Any] | None = sch.get("properties")

args = {}
if plan_args:
for k, v in plan_args.items():
if any_of_type := v.get("anyOf"):
tp_list = []
for typ in any_of_type:
if list_type := typ.get("items"):
tp_list.append(f"list[{list_type.get('type')}]")
elif simple_type := typ.get("type"):
tp_list.append(simple_type)
tp = " | ".join(tp_list)

elif list_type := v.get("items"):
tp = f"list[{list_type.get('type')}]"
else:
tp = v.get("type")

args[f"{k}"] = tp

p = TmpModel(
name=plan.name,
description=plan.description,
parameter_schema=args,
)
format_plans.append(p)

task_list = get_tasks(runner)

context = {
"instrument": runner.instrument(),
"devices": devices,
"plans": format_plans,
"tasks": task_list.tasks,
}

return templates.TemplateResponse(
request=request, name="index.html", context=context
)


@open_router.get("/favicon", include_in_schema=False)
async def favicon():
return FileResponse("docs/images/blueapi-logo.svg")


@secure_router_v1.post("/run", include_in_schema=True, tags=[Tag.TASK])
@start_as_current_span(TRACER)
def run(
name: Annotated[str, Form()],
params: Annotated[str, Form()],
instrument_session: Annotated[str, Form()],
request: Request,
response: Response,
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> RedirectResponse:

task_request = TaskRequest(
name=name,
params=json.loads(params), # do this validator in the model?
instrument_session=instrument_session,
)
res = submit_task(request, response, task_request, runner)

tid = res.task_id
req_task = WorkerTask(task_id=tid)

try:
set_active_task(request, req_task, runner)
except HTTPException:
delete_submitted_task(tid, runner)

return RedirectResponse(status_code=status.HTTP_204_NO_CONTENT, url="/")
5 changes: 5 additions & 0 deletions src/blueapi/service/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ def run(
def state(self) -> EnvironmentResponse:
return self._state

def instrument(self) -> str:
return (
md.instrument if (md := self._config.env.metadata) is not None else "<ixx>"
)


class InvalidRunnerStateError(Exception):
def __init__(self, message):
Expand Down
164 changes: 164 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg" href="/favicon">
<title>{{instrument}}-blueapi</title>
<style>
* {
font-family: sans-serif, Arial, Helvetica,monospace;
}
body {
margin:0;
}
h1 {
width: 100%;
background-color: #202740;
color: white;
text-align: center;
font-weight: lighter;
margin: 0;
}
nav {
background-color: #4C5266;
color: white;
margin: 0;
padding: 0;
}
h2 {
color: #4C5266;
font-weight: lighter;
}
h3 {
color: #3c404e;
font-weight: lighter;
}
dl {
padding-left: 30px;
}
dt {
color: #333744;
}
dd {
color: #333744;
}
.container {
padding: 20px 10px;
display: flex;
}
.container > * {
padding: 10px;
box-shadow: 0 0 10px 5px #eee;
margin: 0 10px;
flex: 1 1 200px;
}
</style>
</head>
<body>
<h1>{{instrument}}-blueapi</h1>
<nav>
api docs available at
<a href="/docs">/docs</a>.
</nav>

<div class="container">

<div>
<h2>Run Plan</h2>
<form action="/api/v1/run" method="POST" id="run_form">
<div>
<label for="instrument_session">Instrument Session</label>
<input type="text" id="instrument_session" name="instrument_session" placeholder="cm1234-5" required/>
</div>

<div>
<label for="name">Select Plan</label>
<select id="name" name="name", form="run_form" required>
<option value="">--select a plan--</option>
{% for plan in plans %}
<option value="{{plan.name}}">{{plan.name}}</option>
{% endfor %}
</select>
</div>

<div>
<textarea id="params" name="params" placeholder="Plan parameters" rows='10' cols='40'></textarea>
</div>

<input type="submit" value="run">
</form>

<h2>Tasks</h2>

<h3>Current task:</h3>
{% for task in tasks if not task.is_pending and not task.is_complete %}
<dl>
<dt> {{task.task_id}}
<ul>
<li>{{task.task.name}}</li>
<li>{{task.task.params}}</li>
</ul>
</dt>
</dl>
{% endfor %}

<h3>Pending tasks:</h3>
{% for task in tasks if task.is_pending %}
<dl>
<dt> {{task.task_id}}
<ul>
<li>{{task.task.name}}</li>
<li>{{task.task.params}}</li>
</ul>
</dt>
</dl>
{% endfor %}

<h3>Completed tasks:</h3>
{% for task in tasks if task.is_complete%}
<dl>
<dt> {{task.task_id}}
<ul>
<li>{{task.task.name}}</li>
<li>{{task.task.params}}</li>
<li>outcome: {{task.outcome.outcome}} </li>
<li>result: {{task.outcome.result}}</li>
</ul>
</dt>
</dl>
{% endfor %}

</div>

<div>
<h2>Plans</h2>
{% for plan in plans %}
<h3>{{plan.name}}</h3>
<dl>
<dt>Description:</dt>
<dd>{{plan.description | replace("\n", "<br>") | safe }}</dd>
<dt>Plan Parameters:</dt>
<dd>
{% for k,v in plan.parameter_schema.items() %}
{{k}}: {{v}} <br>
{% endfor %}
</dd>
</dl>
{% endfor %}
</div>
<div>
<h2>Devices</h2>
{% for device in devices %}
<h3> {{device.device}}</h3>
<dl>
<dt>Protocols:</dt>
<dd>{{device.protocols}}</dd>
</dl>
{% endfor %}
</div>

</div>

</body>
</html>
Loading