feat(api): server-rendered Jinja2 Runtime console at /ui/
Replaces the separate Vue/Vite `console/` SPA with a same-origin, server-rendered console built on a module-level Jinja2 Environment with select_autoescape(["html","xml"]). - Add api/console_web.py with /ui/ routes (dashboard, tasks, task detail/timeline, config) and a _status_fragment polled every 10s. - Refactor api/console.py into a typed ConsoleService shared by the JSON and HTML routers so validation/persistence cannot drift. - Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard CORS middleware from api/rest.py; GET / now redirects to /ui/. - Delete the top-level console/ project; add jinja2 and python-multipart as direct dependencies and ship templates/CSS/JS via package-data. - Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir and no-CORS regressions, wheel-packaging smoke test). /console/* JSON endpoints remain unchanged. The console keeps the trusted-network-only boundary; auth/CSRF is intentionally deferred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+151
-82
@@ -24,58 +24,160 @@ class RuntimeConfigRequest(BaseModel):
|
||||
max_steps: int
|
||||
|
||||
|
||||
def create_console_router(
|
||||
*,
|
||||
device_manager: DeviceManager,
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config_store: DeviceConfigStore,
|
||||
task_runner: TaskRunner,
|
||||
) -> Any:
|
||||
class ConsoleService:
|
||||
"""API-local typed operations shared by JSON and HTML console routers.
|
||||
|
||||
Stays in the ``api`` layer: it only depends on the existing injected
|
||||
Runtime state (device manager, metadata store, timeline, config store,
|
||||
task runner) and does not introduce HTTP/UI concerns below this layer.
|
||||
Page and JSON handlers receive the same instance so validation, driver
|
||||
allow-listing, persistence, and error mapping cannot drift apart.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
device_manager: DeviceManager,
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config_store: DeviceConfigStore,
|
||||
task_runner: TaskRunner,
|
||||
) -> None:
|
||||
self._device_manager = device_manager
|
||||
self._metadata_store = metadata_store
|
||||
self._timeline = timeline
|
||||
self._config_store = config_store
|
||||
self._task_runner = task_runner
|
||||
|
||||
def list_devices(self) -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in self._device_manager.list_devices()]
|
||||
|
||||
def register_device(
|
||||
self,
|
||||
*,
|
||||
driver_type: str,
|
||||
connection_info: dict[str, Any],
|
||||
name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
driver_factory = build_driver_factory(driver_type, connection_info)
|
||||
device_id = uuid4().hex
|
||||
device = self._device_manager.register_device(
|
||||
device_id,
|
||||
driver_factory,
|
||||
name=name,
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
)
|
||||
self._config_store.add(
|
||||
device_id=device_id,
|
||||
name=name,
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
)
|
||||
return device.to_dict()
|
||||
|
||||
def unregister_device(self, device_id: str) -> None:
|
||||
if not self._has_device(device_id):
|
||||
raise KeyError("device not found")
|
||||
self._device_manager.unregister_device(device_id)
|
||||
self._config_store.remove(device_id)
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
*,
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = self._metadata_store.list_tasks()
|
||||
if device_id is not None:
|
||||
rows = [row for row in rows if row["device_id"] == device_id]
|
||||
if status is not None:
|
||||
rows = [row for row in rows if row["status"] == status]
|
||||
return rows
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
task = self._metadata_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise KeyError("task not found")
|
||||
return task
|
||||
|
||||
def get_task_timeline(self, task_id: str) -> list[dict[str, Any]]:
|
||||
if self._metadata_store.get_task(task_id) is None:
|
||||
raise KeyError("task not found")
|
||||
return [
|
||||
self._inline_screenshot(record) for record in self._timeline.read(task_id)
|
||||
]
|
||||
|
||||
def get_runtime_config(self) -> dict[str, int]:
|
||||
return {"max_steps": self._runner_max_steps()}
|
||||
|
||||
def update_runtime_config(self, *, max_steps: int) -> dict[str, int]:
|
||||
if max_steps <= 0:
|
||||
raise ValueError("max_steps must be positive")
|
||||
self._set_runner_max_steps(max_steps)
|
||||
self._config_store.set_setting("max_steps", max_steps)
|
||||
return {"max_steps": max_steps}
|
||||
|
||||
def _has_device(self, device_id: str) -> bool:
|
||||
return any(
|
||||
device.id == device_id for device in self._device_manager.list_devices()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(record)
|
||||
screenshot_path = payload.get("screenshot_path")
|
||||
if screenshot_path:
|
||||
path = Path(str(screenshot_path))
|
||||
if path.exists():
|
||||
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
|
||||
"ascii"
|
||||
)
|
||||
return payload
|
||||
|
||||
def _runner_max_steps(self) -> int:
|
||||
config = getattr(self._task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
return int(config.max_steps)
|
||||
|
||||
def _set_runner_max_steps(self, max_steps: int) -> None:
|
||||
config = getattr(self._task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
config.max_steps = max_steps
|
||||
|
||||
|
||||
def create_console_router(service: ConsoleService) -> Any:
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
router = APIRouter(prefix="/console", tags=["console"])
|
||||
|
||||
@router.get("/devices")
|
||||
def devices() -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in device_manager.list_devices()]
|
||||
return service.list_devices()
|
||||
|
||||
@router.post("/devices", status_code=status.HTTP_201_CREATED)
|
||||
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
|
||||
try:
|
||||
driver_factory = build_driver_factory(
|
||||
request.driver_type,
|
||||
request.connection_info,
|
||||
return service.register_device(
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
name=request.name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
device_id = uuid4().hex
|
||||
device = device_manager.register_device(
|
||||
device_id,
|
||||
driver_factory,
|
||||
name=request.name,
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
)
|
||||
config_store.add(
|
||||
device_id=device_id,
|
||||
name=request.name,
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
)
|
||||
return device.to_dict()
|
||||
|
||||
@router.delete(
|
||||
"/devices/{device_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def unregister_device(device_id: str) -> Response:
|
||||
if not _has_device(device_manager, device_id):
|
||||
raise HTTPException(status_code=404, detail="device not found")
|
||||
device_manager.unregister_device(device_id)
|
||||
config_store.remove(device_id)
|
||||
try:
|
||||
service.unregister_device(device_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@router.get("/tasks")
|
||||
@@ -83,66 +185,33 @@ def create_console_router(
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = metadata_store.list_tasks()
|
||||
if device_id is not None:
|
||||
rows = [row for row in rows if row["device_id"] == device_id]
|
||||
if status is not None:
|
||||
rows = [row for row in rows if row["status"] == status]
|
||||
return rows
|
||||
return service.list_tasks(device_id=device_id, status=status)
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def task_detail(task_id: str) -> dict[str, Any]:
|
||||
task = metadata_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return task
|
||||
try:
|
||||
return service.get_task(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/tasks/{task_id}/timeline")
|
||||
def task_timeline(task_id: str) -> list[dict[str, Any]]:
|
||||
if metadata_store.get_task(task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return [_inline_screenshot(record) for record in timeline.read(task_id)]
|
||||
try:
|
||||
return service.get_task_timeline(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/config")
|
||||
def runtime_config() -> dict[str, int]:
|
||||
return {"max_steps": _runner_max_steps(task_runner)}
|
||||
return service.get_runtime_config()
|
||||
|
||||
@router.put("/config")
|
||||
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
|
||||
if request.max_steps <= 0:
|
||||
raise HTTPException(status_code=400, detail="max_steps must be positive")
|
||||
_set_runner_max_steps(task_runner, request.max_steps)
|
||||
config_store.set_setting("max_steps", request.max_steps)
|
||||
return {"max_steps": request.max_steps}
|
||||
def update_runtime_config(
|
||||
request: RuntimeConfigRequest,
|
||||
) -> dict[str, int]:
|
||||
try:
|
||||
return service.update_runtime_config(max_steps=request.max_steps)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(record)
|
||||
screenshot_path = payload.get("screenshot_path")
|
||||
if screenshot_path:
|
||||
path = Path(str(screenshot_path))
|
||||
if path.exists():
|
||||
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
|
||||
"ascii"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _has_device(device_manager: DeviceManager, device_id: str) -> bool:
|
||||
return any(device.id == device_id for device in device_manager.list_devices())
|
||||
|
||||
|
||||
def _runner_max_steps(task_runner: TaskRunner) -> int:
|
||||
config = getattr(task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
return int(config.max_steps)
|
||||
|
||||
|
||||
def _set_runner_max_steps(task_runner: TaskRunner, max_steps: int) -> None:
|
||||
config = getattr(task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
config.max_steps = max_steps
|
||||
|
||||
Reference in New Issue
Block a user