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:
@@ -7,6 +7,5 @@ __pycache__
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
cloud-console/node_modules
|
||||
cloud-console/dist
|
||||
|
||||
@@ -54,8 +54,12 @@ uv build --package device-agent-runtime
|
||||
uv build --package device-cloud-platform
|
||||
```
|
||||
|
||||
The Vue/Vite application under `console/` remains an independent npm project;
|
||||
uv does not install or modify its JavaScript dependencies.
|
||||
The Runtime API ships a same-origin operator console at `/ui/`, rendered through
|
||||
Jinja2 templates packaged with `device-agent-runtime`. Start the API
|
||||
(`uvicorn api.rest:create_app --factory`) and open `/` (it redirects to `/ui/`).
|
||||
The `/console/*` JSON endpoints remain available for programmatic clients. The
|
||||
console assumes a trusted local network; it has no authentication, authorization,
|
||||
or CSRF protection.
|
||||
|
||||
## Project Direction
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.console import ConsoleService
|
||||
from driver.registry import SUPPORTED_DRIVER_TYPES
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).parent / "templates" / "runtime_console"
|
||||
_STATIC_DIR = Path(__file__).parent / "static" / "runtime_console"
|
||||
|
||||
_ENV = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
auto_reload=False,
|
||||
)
|
||||
|
||||
_TASK_STATUSES = ["created", "running", "completed", "failed", "cancelled"]
|
||||
_DEFAULT_FORM_VALUES: dict[str, str] = {
|
||||
"name": "",
|
||||
"driver_type": "",
|
||||
"server_url": "",
|
||||
"udid": "",
|
||||
"wda_local_port": "",
|
||||
}
|
||||
|
||||
|
||||
def _render(request: Request, template_name: str, **context: Any) -> str:
|
||||
return _ENV.get_template(template_name).render(
|
||||
url_for=request.url_for,
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
def _device_names(service: ConsoleService) -> dict[str, str]:
|
||||
return {
|
||||
device["id"]: device.get("name") or device["id"]
|
||||
for device in service.list_devices()
|
||||
}
|
||||
|
||||
|
||||
def _default_driver_type() -> str:
|
||||
if not _DEFAULT_FORM_VALUES["driver_type"] and SUPPORTED_DRIVER_TYPES:
|
||||
_DEFAULT_FORM_VALUES["driver_type"] = next(iter(SUPPORTED_DRIVER_TYPES))
|
||||
return _DEFAULT_FORM_VALUES["driver_type"]
|
||||
|
||||
|
||||
def _config_context(
|
||||
request: Request,
|
||||
service: ConsoleService,
|
||||
*,
|
||||
form_values: dict[str, str] | None = None,
|
||||
device_error: str = "",
|
||||
device_added: str = "",
|
||||
config_error: str = "",
|
||||
config_saved: bool = False,
|
||||
max_steps_override: int | None = None,
|
||||
) -> str:
|
||||
form = dict(_DEFAULT_FORM_VALUES)
|
||||
form["driver_type"] = _default_driver_type()
|
||||
if form_values:
|
||||
form.update(form_values)
|
||||
config = service.get_runtime_config()
|
||||
return _render(
|
||||
request,
|
||||
"config.html",
|
||||
active_section="config",
|
||||
devices=service.list_devices(),
|
||||
max_steps=max_steps_override
|
||||
if max_steps_override is not None
|
||||
else config["max_steps"],
|
||||
supported_driver_types=list(SUPPORTED_DRIVER_TYPES),
|
||||
form_values=form,
|
||||
device_error=device_error,
|
||||
device_added=device_added,
|
||||
config_error=config_error,
|
||||
config_saved=config_saved,
|
||||
)
|
||||
|
||||
|
||||
def create_console_web_router(service: ConsoleService) -> APIRouter:
|
||||
router = APIRouter(prefix="/ui", tags=["console-ui"])
|
||||
|
||||
@router.get("/", name="runtime_console_dashboard", include_in_schema=False)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
devices = service.list_devices()
|
||||
tasks = service.list_tasks()
|
||||
running = sum(1 for task in tasks if task.get("status") == "running")
|
||||
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
active_section="dashboard",
|
||||
devices=devices,
|
||||
device_count=len(devices),
|
||||
task_count=len(tasks),
|
||||
running_tasks=running,
|
||||
failed_tasks=failed,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/_status_fragment",
|
||||
name="runtime_console_status_fragment",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def status_fragment(request: Request) -> HTMLResponse:
|
||||
devices = service.list_devices()
|
||||
tasks = service.list_tasks()
|
||||
running = sum(1 for task in tasks if task.get("status") == "running")
|
||||
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"_status_fragment.html",
|
||||
devices=devices,
|
||||
device_count=len(devices),
|
||||
running_tasks=running,
|
||||
failed_tasks=failed,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/tasks", name="runtime_console_tasks", include_in_schema=False)
|
||||
def tasks(
|
||||
request: Request,
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
service_tasks = service.list_tasks(device_id=device_id, status=status)
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"tasks.html",
|
||||
active_section="tasks",
|
||||
tasks=service_tasks,
|
||||
devices=service.list_devices(),
|
||||
device_names=_device_names(service),
|
||||
task_statuses=_TASK_STATUSES,
|
||||
selected_device_id=device_id or "",
|
||||
selected_status=status or "",
|
||||
)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}",
|
||||
name="runtime_console_task_detail",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def task_detail(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
step: int | None = None,
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
task = service.get_task(task_id)
|
||||
timeline = service.get_task_timeline(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if timeline:
|
||||
index = 0
|
||||
if step is not None:
|
||||
index = max(0, min(step, len(timeline) - 1))
|
||||
current_step = timeline[index]
|
||||
else:
|
||||
index = 0
|
||||
current_step = None
|
||||
device_name = _device_names(service).get(
|
||||
task.get("device_id", ""),
|
||||
task.get("device_id", ""),
|
||||
)
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"task_detail.html",
|
||||
active_section="tasks",
|
||||
task=task,
|
||||
device_name=device_name,
|
||||
timeline=timeline,
|
||||
current_step=current_step if current_step is not None else {},
|
||||
current_step_index=index,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/config", name="runtime_console_config", include_in_schema=False)
|
||||
def config(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(_config_context(request, service))
|
||||
|
||||
@router.post(
|
||||
"/config/devices",
|
||||
name="runtime_console_register_device",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def register_device(request: Request) -> HTMLResponse | RedirectResponse:
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()
|
||||
driver_type = (form.get("driver_type") or "").strip()
|
||||
server_url = (form.get("server_url") or "").strip()
|
||||
udid = (form.get("udid") or "").strip()
|
||||
wda_local_port_raw = (form.get("wda_local_port") or "").strip()
|
||||
|
||||
connection_info: dict[str, Any] = {}
|
||||
if server_url:
|
||||
connection_info["server_url"] = server_url
|
||||
if udid:
|
||||
connection_info["udid"] = udid
|
||||
if wda_local_port_raw:
|
||||
try:
|
||||
connection_info["wda_local_port"] = int(wda_local_port_raw)
|
||||
except ValueError:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
form_values={
|
||||
"name": name,
|
||||
"driver_type": driver_type,
|
||||
"server_url": server_url,
|
||||
"udid": udid,
|
||||
"wda_local_port": wda_local_port_raw,
|
||||
},
|
||||
device_error="wda_local_port must be a number",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
service.register_device(
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
name=name or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
form_values={
|
||||
"name": name,
|
||||
"driver_type": driver_type,
|
||||
"server_url": server_url,
|
||||
"udid": udid,
|
||||
"wda_local_port": wda_local_port_raw,
|
||||
},
|
||||
device_error=str(exc),
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/config/devices/{device_id}/delete",
|
||||
name="runtime_console_remove_device",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def remove_device(
|
||||
request: Request,
|
||||
device_id: str,
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
try:
|
||||
service.unregister_device(device_id)
|
||||
except KeyError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(request, service, device_error=str(exc)),
|
||||
status_code=404,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/config/max-steps",
|
||||
name="runtime_console_update_max_steps",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def update_max_steps(request: Request) -> HTMLResponse | RedirectResponse:
|
||||
form = await request.form()
|
||||
raw = (form.get("max_steps") or "").strip()
|
||||
try:
|
||||
max_steps = int(raw)
|
||||
except ValueError:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
config_error="max_steps must be an integer",
|
||||
max_steps_override=raw if raw else None,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
service.update_runtime_config(max_steps=max_steps)
|
||||
except ValueError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
config_error=str(exc),
|
||||
max_steps_override=max_steps,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def mount_console_assets(app: Any) -> None:
|
||||
"""Mount the package-owned static assets under ``/ui/assets``.
|
||||
|
||||
Kept as a separate helper so ``api.rest`` can mount assets with a stable
|
||||
route name that templates reference via ``url_for``.
|
||||
"""
|
||||
app.mount(
|
||||
"/ui/assets",
|
||||
StaticFiles(directory=str(_STATIC_DIR)),
|
||||
name="runtime_console_assets",
|
||||
)
|
||||
+17
-56
@@ -1,8 +1,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.console import create_console_router
|
||||
from api.console import ConsoleService, create_console_router
|
||||
from api.console_web import create_console_web_router, mount_console_assets
|
||||
from api.errors import semantic_error
|
||||
from core.models import Task
|
||||
from device.manager import DEFAULT_MANAGER, DeviceManager
|
||||
@@ -26,28 +25,8 @@ def create_app(
|
||||
timeline: Timeline | None = None,
|
||||
) -> Any:
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.types import Scope
|
||||
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
|
||||
|
||||
Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown
|
||||
path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the
|
||||
SPA's client-side router handle it.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Any:
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code == 404 and path != "index.html":
|
||||
return await super().get_response("index.html", scope)
|
||||
raise
|
||||
|
||||
device_manager = manager or DEFAULT_MANAGER
|
||||
store = metadata_store or TaskMetadataStore()
|
||||
@@ -63,12 +42,6 @@ def create_app(
|
||||
)
|
||||
_apply_max_steps(runner, max_steps)
|
||||
app = FastAPI(title="Apex Agent API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class TapRequest(BaseModel):
|
||||
x: float
|
||||
@@ -85,6 +58,18 @@ def create_app(
|
||||
goal: str
|
||||
device_id: str
|
||||
|
||||
console_service = ConsoleService(
|
||||
device_manager=device_manager,
|
||||
metadata_store=store,
|
||||
timeline=timeline_store,
|
||||
config_store=config_store,
|
||||
task_runner=runner,
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
@app.get("/devices")
|
||||
def devices() -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in device_manager.list_devices()]
|
||||
@@ -139,33 +124,9 @@ def create_app(
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return task
|
||||
|
||||
app.include_router(
|
||||
create_console_router(
|
||||
device_manager=device_manager,
|
||||
metadata_store=store,
|
||||
timeline=timeline_store,
|
||||
config_store=config_store,
|
||||
task_runner=runner,
|
||||
)
|
||||
)
|
||||
|
||||
console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR")
|
||||
if console_static_dir:
|
||||
dist_dir = Path(console_static_dir)
|
||||
if not dist_dir.is_dir():
|
||||
raise ValueError(
|
||||
f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def _redirect_to_console() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
app.mount(
|
||||
"/ui",
|
||||
SpaStaticFiles(directory=str(dist_dir), html=True),
|
||||
name="console",
|
||||
)
|
||||
app.include_router(create_console_router(console_service))
|
||||
app.include_router(create_console_web_router(console_service))
|
||||
mount_console_assets(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
@@ -57,6 +62,18 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #2f7c67;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
@@ -140,13 +157,6 @@ button:disabled {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -336,7 +346,7 @@ select {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row.selected {
|
||||
.task-row:hover {
|
||||
border-color: #2f7c67;
|
||||
box-shadow: 0 0 0 2px #d9efe8;
|
||||
}
|
||||
@@ -480,16 +490,6 @@ pre {
|
||||
color: #1f6b4a;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -498,55 +498,21 @@ pre {
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9dde5;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.tasks-layout,
|
||||
.timeline-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-browser {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.workspace {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.section-title,
|
||||
.device-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.device-row,
|
||||
.settings-form {
|
||||
flex-direction: column;
|
||||
.detail-grid,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.filters,
|
||||
.form-grid,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Narrow server-rendered live-status enhancement. Fetches an HTML fragment
|
||||
// from the same origin and replaces only the dashboard live region. Does
|
||||
// not parse JSON, rebuild DOM from client state, or depend on any framework.
|
||||
(function () {
|
||||
"use strict";
|
||||
var region = document.getElementById("live-status");
|
||||
if (!region) {
|
||||
return;
|
||||
}
|
||||
setInterval(function () {
|
||||
fetch("/ui/_status_fragment", { headers: { Accept: "text/html" } })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("fragment refresh failed");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
region.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
// Ignore transient network errors; the next tick will retry.
|
||||
});
|
||||
}, 10000);
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ device_count }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ running_tasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failed_tasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
<div class="empty-state">
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="device-list">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill {{ device.status }}">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Apex Console{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('runtime_console_assets', path='console.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<span class="brand-icon" aria-hidden="true">AM</span>
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>Runtime /ui</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list">
|
||||
<a
|
||||
class="nav-button{% if active_section == 'dashboard' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_dashboard') }}"
|
||||
>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'tasks' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_tasks') }}"
|
||||
>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'config' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_config') }}"
|
||||
>
|
||||
<span>Config</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="workspace">
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Config · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Config</h1>
|
||||
<p>Register devices and tune Runtime parameters.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
{% if device_error %}
|
||||
<p class="alert error">{{ device_error }}</p>
|
||||
{% endif %}
|
||||
{% if device_added %}
|
||||
<p class="alert success">Device "{{ device_added }}" registered.</p>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{{ url_for('runtime_console_register_device') }}">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value="{{ form_values.name }}" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_type">
|
||||
{% for driver_type in supported_driver_types %}
|
||||
<option
|
||||
value="{{ driver_type }}"
|
||||
{% if driver_type == form_values.driver_type %}selected{% endif %}
|
||||
>{{ driver_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input type="url" name="server_url" value="{{ form_values.server_url }}" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input type="text" name="udid" value="{{ form_values.udid }}" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
name="wda_local_port"
|
||||
value="{{ form_values.wda_local_port }}"
|
||||
/>
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul class="device-list managed">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('runtime_console_remove_device', device_id=device.id) }}">
|
||||
<button class="icon-button danger" type="submit" title="Remove device" aria-label="Remove device">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
{% if config_error %}
|
||||
<p class="alert error">{{ config_error }}</p>
|
||||
{% endif %}
|
||||
{% if config_saved %}
|
||||
<p class="alert success">Saved.</p>
|
||||
{% endif %}
|
||||
<form class="settings-form" method="post" action="{{ url_for('runtime_console_update_max_steps') }}">
|
||||
<label>
|
||||
Max steps
|
||||
<input type="number" min="1" name="max_steps" value="{{ max_steps }}" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Devices · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>{{ device_count }} device(s) / {{ task_count }} task(s)</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="view-grid">
|
||||
<div id="live-status">
|
||||
{% include "_status_fragment.html" %}
|
||||
</div>
|
||||
</section>
|
||||
<script src="{{ url_for('runtime_console_assets', path='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Task {{ task.id }} · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Task Detail</h1>
|
||||
<p><a href="{{ url_for('runtime_console_tasks') }}">← Back to tasks</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>{{ task.goal }}</h2>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Task ID</dt>
|
||||
<dd>{{ task.id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ device_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ task.created_at or "-" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ task.updated_at or "-" }}</dd>
|
||||
</div>
|
||||
{% if task.failure_reason %}
|
||||
<div>
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ task.failure_reason }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<span>Step {{ current_step_index }} of {{ timeline|length }}</span>
|
||||
</div>
|
||||
|
||||
{% if not timeline %}
|
||||
<div class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="timeline-controls" method="get" action="{{ url_for('runtime_console_task_detail', task_id=task.id) }}">
|
||||
<label>
|
||||
Step
|
||||
<select name="step" onchange="this.form.submit()">
|
||||
{% for record in timeline %}
|
||||
<option
|
||||
value="{{ loop.index0 }}"
|
||||
{% if loop.index0 == current_step_index %}selected{% endif %}
|
||||
>Step {{ loop.index }}{% if record.tool_call and record.tool_call.get('action') %} ({{ record.tool_call.get('action') }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Show</span></button></noscript>
|
||||
</form>
|
||||
|
||||
<div class="timeline-stage">
|
||||
<div class="screenshot-frame">
|
||||
{% if current_step.image_base64 %}
|
||||
<img
|
||||
src="data:image/png;base64,{{ current_step.image_base64 }}"
|
||||
alt="Task step screenshot"
|
||||
/>
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Tool Call</h3>
|
||||
<pre>{{ current_step.tool_call | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Result</h3>
|
||||
<pre>{{ current_step.result | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tasks · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Tasks</h1>
|
||||
<p>{{ tasks|length }} task(s) match the current filters</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<form class="filters" method="get" action="{{ url_for('runtime_console_tasks') }}">
|
||||
<label>
|
||||
Device
|
||||
<select name="device_id">
|
||||
<option value="">All devices</option>
|
||||
{% for device in devices %}
|
||||
<option
|
||||
value="{{ device.id }}"
|
||||
{% if device.id == selected_device_id %}selected{% endif %}
|
||||
>{{ device.name or device.id }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{% for status_name in task_statuses %}
|
||||
<option
|
||||
value="{{ status_name }}"
|
||||
{% if status_name == selected_status %}selected{% endif %}
|
||||
>{{ status_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Apply</span></button></noscript>
|
||||
</form>
|
||||
|
||||
{% if not tasks %}
|
||||
<div class="empty-state compact">
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for task in tasks %}
|
||||
<a
|
||||
class="task-row"
|
||||
href="{{ url_for('runtime_console_task_detail', task_id=task.id) }}"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ device_names.get(task.device_id, task.device_id) }}
|
||||
</span>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -1,46 +0,0 @@
|
||||
# Apex Agent Console
|
||||
|
||||
Independent Vue 3 + Vite SPA for the operator console.
|
||||
|
||||
## Run Locally
|
||||
|
||||
Start the backend from the repository root:
|
||||
|
||||
```bash
|
||||
uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Start the frontend from this directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`.
|
||||
Copy `.env.example` to `.env.local` if the backend runs on another host or port.
|
||||
|
||||
The backend mounts `/console/*` routes and enables permissive CORS in `create_app()`
|
||||
for local frontend development.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Same-Origin, Single-Process Mode
|
||||
|
||||
For an edge/dev setup where running a separate `npm run dev` process is too heavy,
|
||||
the backend can serve the built console directly from the same process:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL= npm run build
|
||||
RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works
|
||||
same-origin without CORS. The console is then served at `/ui/` (with `/`
|
||||
redirecting there); `/console/*` remains the JSON API used by both this mode
|
||||
and local `npm run dev`. Rebuild (`npm run build`) after frontend changes —
|
||||
this mode does not hot-reload.
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Apex Agent Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1211
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "apex-agent-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.23.0",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vue-tsc": "^3.3.6"
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
MonitorSmartphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
getConfig,
|
||||
getTask,
|
||||
getTimeline,
|
||||
listDevices,
|
||||
listTasks,
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
updateConfig,
|
||||
} from "./api";
|
||||
import type { Device, TaskRecord, TimelineRecord } from "./types";
|
||||
|
||||
type ViewId = "dashboard" | "tasks" | "config";
|
||||
|
||||
const navItems: { id: ViewId; label: string; icon: Component }[] = [
|
||||
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
|
||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||
{ id: "config", label: "Config", icon: Settings2 },
|
||||
];
|
||||
|
||||
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
|
||||
|
||||
const activeView = ref<ViewId>("dashboard");
|
||||
const loading = ref(false);
|
||||
const refreshError = ref("");
|
||||
const devices = ref<Device[]>([]);
|
||||
const tasks = ref<TaskRecord[]>([]);
|
||||
const selectedTask = ref<TaskRecord | null>(null);
|
||||
const timeline = ref<TimelineRecord[]>([]);
|
||||
const selectedStepIndex = ref(0);
|
||||
const deviceFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
const deviceError = ref("");
|
||||
const configError = ref("");
|
||||
const configSaved = ref("");
|
||||
const maxSteps = ref(20);
|
||||
const deviceForm = reactive({
|
||||
name: "",
|
||||
driver_type: "wda",
|
||||
server_url: "http://127.0.0.1:4723",
|
||||
udid: "",
|
||||
wda_local_port: "",
|
||||
});
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
const currentStep = computed<TimelineRecord | null>(() => {
|
||||
if (!timeline.value.length) {
|
||||
return null;
|
||||
}
|
||||
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
|
||||
});
|
||||
|
||||
const runningTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "running").length,
|
||||
);
|
||||
|
||||
const failedTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "failed").length,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
refreshError.value = "";
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices(): Promise<void> {
|
||||
devices.value = await listDevices();
|
||||
}
|
||||
|
||||
async function refreshTasks(): Promise<void> {
|
||||
tasks.value = await listTasks({
|
||||
deviceId: deviceFilter.value,
|
||||
status: statusFilter.value,
|
||||
});
|
||||
if (selectedTask.value) {
|
||||
await openTask(selectedTask.value.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig(): Promise<void> {
|
||||
const config = await getConfig();
|
||||
maxSteps.value = config.max_steps;
|
||||
}
|
||||
|
||||
async function applyTaskFilters(): Promise<void> {
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
async function openTask(taskId: string, switchView = true): Promise<void> {
|
||||
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
|
||||
selectedTask.value = task;
|
||||
timeline.value = records;
|
||||
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
|
||||
if (switchView) {
|
||||
activeView.value = "tasks";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDevice(): Promise<void> {
|
||||
deviceError.value = "";
|
||||
const connectionInfo: Record<string, unknown> = {};
|
||||
if (deviceForm.server_url.trim()) {
|
||||
connectionInfo.server_url = deviceForm.server_url.trim();
|
||||
}
|
||||
if (deviceForm.udid.trim()) {
|
||||
connectionInfo.udid = deviceForm.udid.trim();
|
||||
}
|
||||
if (deviceForm.wda_local_port.trim()) {
|
||||
const port = Number(deviceForm.wda_local_port);
|
||||
if (!Number.isFinite(port)) {
|
||||
deviceError.value = "wda_local_port must be a number";
|
||||
return;
|
||||
}
|
||||
connectionInfo.wda_local_port = port;
|
||||
}
|
||||
|
||||
try {
|
||||
await registerDevice({
|
||||
driver_type: deviceForm.driver_type,
|
||||
name: deviceForm.name.trim() || null,
|
||||
connection_info: connectionInfo,
|
||||
});
|
||||
deviceForm.name = "";
|
||||
deviceForm.udid = "";
|
||||
deviceForm.wda_local_port = "";
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(device: Device): Promise<void> {
|
||||
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
|
||||
return;
|
||||
}
|
||||
deviceError.value = "";
|
||||
try {
|
||||
await unregisterDevice(device.id);
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
configError.value = "";
|
||||
configSaved.value = "";
|
||||
try {
|
||||
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
|
||||
maxSteps.value = updated.max_steps;
|
||||
configSaved.value = "Saved";
|
||||
} catch (error) {
|
||||
configError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep(): void {
|
||||
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
|
||||
}
|
||||
|
||||
function nextStep(): void {
|
||||
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
|
||||
}
|
||||
|
||||
function displayDeviceName(device: Device): string {
|
||||
return device.name || device.id;
|
||||
}
|
||||
|
||||
function findDeviceName(deviceId: string): string {
|
||||
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Request failed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<MonitorSmartphone :size="22" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>{{ API_BASE_URL }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
class="nav-button"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
@click="activeView = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="18" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
|
||||
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
|
||||
</div>
|
||||
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
|
||||
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
|
||||
<RefreshCw v-else :size="17" aria-hidden="true" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
|
||||
|
||||
<section v-if="activeView === 'dashboard'" class="view-grid">
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ devices.length }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ runningTasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failedTasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
<div v-if="!devices.length" class="empty-state">
|
||||
<MonitorSmartphone :size="34" aria-hidden="true" />
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
<ul v-else class="device-list">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill" :class="device.status">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'tasks'" class="tasks-layout">
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>
|
||||
Device
|
||||
<select v-model="deviceFilter" @change="applyTaskFilters">
|
||||
<option value="">All devices</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ displayDeviceName(device) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="statusFilter" @change="applyTaskFilters">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
|
||||
{{ statusName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!tasks.length" class="empty-state compact">
|
||||
<ListChecks :size="30" aria-hidden="true" />
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
v-else
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ selected: selectedTask?.id === task.id }"
|
||||
type="button"
|
||||
@click="openTask(task.id)"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="task.status">{{ task.status }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>Task Detail</h2>
|
||||
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
|
||||
{{ selectedTask.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedTask" class="empty-state">
|
||||
<ListChecks :size="34" aria-hidden="true" />
|
||||
<span>Select a task to inspect its timeline.</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-detail">
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Goal</dt>
|
||||
<dd>{{ selectedTask.goal }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="selectedTask.failure_reason">
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ selectedTask.failure_reason }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Previous step"
|
||||
:disabled="selectedStepIndex === 0"
|
||||
@click="previousStep"
|
||||
>
|
||||
<ChevronLeft :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Next step"
|
||||
:disabled="selectedStepIndex >= timeline.length - 1"
|
||||
@click="nextStep"
|
||||
>
|
||||
<ChevronRight :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentStep" class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
<div v-else class="timeline-stage">
|
||||
<div class="screenshot-frame">
|
||||
<img
|
||||
v-if="currentStep.image_base64"
|
||||
:src="`data:image/png;base64,${currentStep.image_base64}`"
|
||||
alt="Task step screenshot"
|
||||
/>
|
||||
<span v-else>No screenshot</span>
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Tool Call</h3>
|
||||
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Result</h3>
|
||||
<pre>{{ prettyJson(currentStep.result) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'config'" class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
<form class="form-grid" @submit.prevent="submitDevice">
|
||||
<label>
|
||||
Name
|
||||
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select v-model="deviceForm.driver_type">
|
||||
<option value="wda">wda</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input v-model="deviceForm.server_url" type="url" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input v-model="deviceForm.udid" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<Plus :size="17" aria-hidden="true" />
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
|
||||
|
||||
<ul class="device-list managed">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button danger"
|
||||
type="button"
|
||||
title="Remove device"
|
||||
@click="removeDevice(device)"
|
||||
>
|
||||
<Trash2 :size="17" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
<form class="settings-form" @submit.prevent="saveConfig">
|
||||
<label>
|
||||
Max steps
|
||||
<input v-model.number="maxSteps" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<Save :size="17" aria-hidden="true" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="configError" class="alert error">{{ configError }}</p>
|
||||
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,96 +0,0 @@
|
||||
import type {
|
||||
Device,
|
||||
RegisterDevicePayload,
|
||||
RuntimeConfig,
|
||||
TaskRecord,
|
||||
TimelineRecord,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
export const API_BASE_URL = (
|
||||
configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
message = payload.detail;
|
||||
} else if (payload.detail) {
|
||||
message = JSON.stringify(payload.detail);
|
||||
}
|
||||
} catch {
|
||||
message = await response.text();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function listDevices(): Promise<Device[]> {
|
||||
return request<Device[]>("/console/devices");
|
||||
}
|
||||
|
||||
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
|
||||
return request<Device>("/console/devices", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterDevice(deviceId: string): Promise<void> {
|
||||
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listTasks(filters: {
|
||||
deviceId?: string;
|
||||
status?: string;
|
||||
}): Promise<TaskRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.deviceId) {
|
||||
params.set("device_id", filters.deviceId);
|
||||
}
|
||||
if (filters.status) {
|
||||
params.set("status", filters.status);
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export function getTask(taskId: string): Promise<TaskRecord> {
|
||||
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
|
||||
return request<TimelineRecord[]>(
|
||||
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config");
|
||||
}
|
||||
|
||||
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -1,48 +0,0 @@
|
||||
export type DeviceStatus = "idle" | "busy" | "offline" | "error";
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string | null;
|
||||
status: DeviceStatus;
|
||||
driver_type: string;
|
||||
connection_info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TaskStatus =
|
||||
| "created"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
goal: string;
|
||||
device_id: string;
|
||||
status: TaskStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export interface TimelineRecord {
|
||||
index: number;
|
||||
scene: Record<string, unknown>;
|
||||
prompt: string;
|
||||
tool_call: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
screenshot_path?: string | null;
|
||||
image_base64?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
max_steps: number;
|
||||
}
|
||||
|
||||
export interface RegisterDevicePayload {
|
||||
driver_type: string;
|
||||
name?: string | null;
|
||||
connection_info: Record<string, unknown>;
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/ui/",
|
||||
});
|
||||
@@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
|
||||
explicit workspace source; the Runtime distribution must never depend on or
|
||||
package `cloud`.
|
||||
|
||||
All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
|
||||
remains outside the Python workspace and keeps its independent npm lifecycle.
|
||||
All Python members share the committed root `uv.lock`. The Runtime operator
|
||||
console is server-rendered by the `api` layer through Jinja2 templates and
|
||||
static assets packaged with `device-agent-runtime`; there is no separate
|
||||
frontend project or Node build step for the Runtime console. The unrelated
|
||||
`cloud-console/` Vue/Vite application keeps its own independent npm lifecycle.
|
||||
|
||||
## Change Discipline
|
||||
|
||||
|
||||
+11
-15
@@ -331,23 +331,19 @@ curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
|
||||
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
|
||||
误操作。
|
||||
|
||||
如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
|
||||
Runtime API 自带同源 Web Console,无需额外的前端进程、Node 工具链或
|
||||
`RUNTIME_CONSOLE_STATIC_DIR`。保持 Runtime API 运行,浏览器访问
|
||||
`http://127.0.0.1:8000/`(会自动 307 跳转到 `/ui/`)即可:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
- `/ui/`:设备状态面板,约每 10 秒自动刷新一次;
|
||||
- `/ui/tasks`:任务列表与筛选;
|
||||
- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图);
|
||||
- `/ui/config`:登记/移除设备、调整 `max_steps`。
|
||||
|
||||
Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
|
||||
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
|
||||
操作只写入配置,不会自动 connect。
|
||||
|
||||
如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
|
||||
Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
|
||||
Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
|
||||
指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
|
||||
`npm run build`,不支持热更新。
|
||||
已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中
|
||||
重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与
|
||||
`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console
|
||||
仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。
|
||||
|
||||
## 9. 启动云端受管 Host Agent
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
@@ -0,0 +1,233 @@
|
||||
## Context
|
||||
|
||||
The root Runtime API currently exposes console state and mutations through
|
||||
`api/console.py` under `/console/*`. Its human interface is a separate
|
||||
`console/` Vue/Vite SPA. `api/rest.py` only serves that SPA when
|
||||
`RUNTIME_CONSOLE_STATIC_DIR` points to a built distribution, uses a
|
||||
SPA-specific 404 fallback, and enables wildcard CORS for cross-origin Vite
|
||||
development.
|
||||
|
||||
The existing `web-console` change is still unarchived and contains the
|
||||
opposite design decision: an independent SPA with no backend templates. There
|
||||
is no canonical Runtime-console spec in `openspec/specs/`, so this change
|
||||
introduces a new capability rather than modifying a pending change's delta
|
||||
spec. The Host Agent already uses Jinja2, but it is a distinct application
|
||||
with local-account authentication and must not become a dependency of the
|
||||
Runtime API.
|
||||
|
||||
The Runtime layering rule requires all HTTP, template, static-asset, and form
|
||||
parsing concerns to remain in `api`. `core`, `driver`, `device`, `tools`,
|
||||
`perception`, `storage`, and `runtime` retain their current framework-free
|
||||
contracts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Serve the Runtime operator console from the same FastAPI process at `/ui/`
|
||||
without Node, Vite, a prebuilt SPA directory, or a second development
|
||||
process.
|
||||
- Preserve the observable behavior of the existing device, task, timeline,
|
||||
and runtime-config console workflows, while retaining `/console/*` JSON
|
||||
endpoints for programmatic clients.
|
||||
- Ensure every HTML interpolation is protected by Jinja2 autoescaping and
|
||||
ship templates and assets in the Runtime wheel.
|
||||
- Keep mutations server-owned through ordinary POST/Redirect/GET form flows
|
||||
and reuse one API-local implementation for page and JSON routes.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Add authentication, authorization, sessions, CSRF protection, rate limits,
|
||||
or a public-network deployment model for the Runtime console.
|
||||
- Change `/agent/task`, `/devices`, MCP, `DeviceManager`, `TaskRunner`,
|
||||
`TaskMetadataStore`, `Timeline`, or persisted data formats.
|
||||
- Add new driver types, editable planner settings, Cloud Console features, or
|
||||
Host Agent console features.
|
||||
- Retain a generic client-side application framework or a Node build pipeline.
|
||||
- Edit or archive the pending `web-console` change as part of this change.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Add a Runtime page router in `api/console_web.py`
|
||||
|
||||
`api/console_web.py` will expose `create_console_web_router()` with the `/ui`
|
||||
prefix. `api/rest.py::create_app()` will build the existing Runtime stores and
|
||||
manager once, construct the console service once, and include both the JSON
|
||||
router and the page router in the same FastAPI application. `GET /` will
|
||||
always redirect to `/ui/` because the console is no longer optional build
|
||||
output.
|
||||
|
||||
The page surface will use stable server routes:
|
||||
|
||||
- `GET /ui/` for the device/status dashboard;
|
||||
- `GET /ui/tasks` for task filtering and browsing;
|
||||
- `GET /ui/tasks/{task_id}` for one task and its ordered timeline;
|
||||
- `GET /ui/config` for device registration/removal and `max_steps`;
|
||||
- POST routes below `/ui/` for device registration, device removal, and
|
||||
configuration updates.
|
||||
|
||||
Successful POST handlers use `303 See Other` redirects. Validation failures
|
||||
return the originating page with an HTML `400` response and preserved safe
|
||||
form values. This is preferred over trying to preserve the SPA's client-side
|
||||
state machine because normal browser navigation and forms are sufficient for
|
||||
the console's low-frequency operational actions.
|
||||
|
||||
An alternative of replacing `/console/*` JSON endpoints with HTML endpoints
|
||||
was rejected: the JSON API has explicit tests and remains useful to scripts
|
||||
and future clients. An alternative of a separate FastAPI app was rejected:
|
||||
it would duplicate the `DeviceManager`, stores, and `TaskRunner` composition.
|
||||
|
||||
### D2: Share API-local console operations rather than issuing loopback HTTP
|
||||
requests
|
||||
|
||||
`api/console.py` will gain a small API-local `ConsoleService` (or equivalent
|
||||
typed operation object) that owns the current console reads and mutations:
|
||||
device listing/registration/removal, task listing/detail/timeline lookup, and
|
||||
runtime configuration reads/updates. The existing JSON router and the new
|
||||
page router receive the same service instance.
|
||||
|
||||
The service stays in `api` and delegates to the existing injected
|
||||
`DeviceManager`, `TaskMetadataStore`, `Timeline`, `DeviceConfigStore`, and
|
||||
`TaskRunner`. Page handlers MUST NOT make HTTP requests to the process's own
|
||||
`/console/*` endpoints. This keeps validation, driver allow-listing,
|
||||
persistence, error mapping, and state mutation single-sourced without moving
|
||||
web concepts into a lower layer.
|
||||
|
||||
Duplicating the route bodies was rejected because future changes could make
|
||||
JSON and form behavior diverge. Moving the service into `runtime` or
|
||||
`storage` was rejected because it would introduce HTTP/UI-oriented
|
||||
application concerns below the API adapter boundary.
|
||||
|
||||
### D3: Use a module-level Jinja2 environment and package-owned assets
|
||||
|
||||
`api/console_web.py` will construct one module-level Jinja2 `Environment`
|
||||
using `FileSystemLoader` rooted at `api/templates/runtime_console/` and
|
||||
`select_autoescape(["html", "xml"])`. Route handlers will render with
|
||||
`get_template(...).render(...)` and return `HTMLResponse`; they will not
|
||||
build HTML through f-strings, string concatenation, or post-process rendered
|
||||
HTML.
|
||||
|
||||
Templates will include a shared `base.html`, dashboard, task list, task
|
||||
detail, configuration page, and any small server-rendered dashboard fragment.
|
||||
CSS and the narrow polling script will be regular package assets mounted under
|
||||
`/ui/assets`, not Vite output. The root package will explicitly include the
|
||||
template, CSS, and JavaScript globs in setuptools package data so an installed
|
||||
wheel behaves like an editable checkout.
|
||||
|
||||
`jinja2>=3.1` and `python-multipart` will be direct root dependencies. The
|
||||
latter is declared directly even though it is currently available through an
|
||||
unrelated transitive dependency, because `Request.form()` is part of this
|
||||
feature's runtime contract.
|
||||
|
||||
`starlette.templating.Jinja2Templates` was rejected because direct Jinja2
|
||||
rendering is explicit, matches the existing Host Agent convention, and avoids
|
||||
coupling the implementation to helper signature changes. Inline all-CSS HTML
|
||||
was rejected because a dedicated static stylesheet keeps base templates
|
||||
readable without restoring a frontend build system.
|
||||
|
||||
### D4: Retain server-owned live status with a narrow rendered fragment
|
||||
|
||||
The dashboard will keep the current approximately 10-second live-status
|
||||
refresh through a small static browser script. It fetches an HTML fragment
|
||||
rendered by the same Jinja2 environment and replaces only the dashboard's
|
||||
live-status region. The browser does not fetch JSON and recreate console DOM
|
||||
state; all stateful markup still originates on the server.
|
||||
|
||||
Task filters, task selection, timeline step selection, device mutations, and
|
||||
configuration updates use normal GET/POST navigation. This retains useful
|
||||
status freshness without reintroducing a SPA framework or making a full-page
|
||||
reload the only refresh mechanism.
|
||||
|
||||
A zero-JavaScript periodic refresh was rejected because it would require full
|
||||
page reloads and regress the existing dashboard behavior. Reusing the Vue
|
||||
reactivity layer was rejected because it retains the independent build and
|
||||
deployment boundary this change removes.
|
||||
|
||||
### D5: Preserve JSON compatibility while retiring SPA deployment wiring
|
||||
|
||||
The `/console/*` paths, methods, success payloads, error status codes, and
|
||||
persistence semantics remain unchanged. Existing `tests/test_console_api.py`
|
||||
continues to be the compatibility baseline. `api/rest.py` removes
|
||||
`RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, and its SPA-only fallback.
|
||||
The broad CORS middleware added only for cross-origin Vite development is
|
||||
removed; same-origin `/ui/` must not depend on it.
|
||||
|
||||
The top-level `console/` directory, Vite environment files, package lockfile,
|
||||
and npm instructions are removed. Root documentation changes to a single
|
||||
Runtime startup command followed by `/ui/`. The unrelated `cloud-console/`
|
||||
SPA and its deployment remain unchanged.
|
||||
|
||||
Keeping `RUNTIME_CONSOLE_STATIC_DIR` as a deprecated fallback was rejected:
|
||||
it would preserve an unsupported second rendering path and force every future
|
||||
console change to be tested twice. A backward-compatible redirect from the
|
||||
old external Vite development port is impossible because it is a separate
|
||||
process, so the operator migration is documented as a breaking change.
|
||||
|
||||
### D6: Apply autoescaping uniformly and retain the trusted-network boundary
|
||||
|
||||
All interpolated device names, IDs, connection values, task goals, failure
|
||||
reasons, timeline text, JSON-like tool/result data, and errors pass through
|
||||
the shared autoescaping environment. Structured values use Jinja's `tojson`
|
||||
filter only in safe text contexts; no current template uses `|safe` or a
|
||||
global autoescape opt-out. Screenshot bytes remain data sourced from the
|
||||
existing timeline and are rendered only as the established PNG data URI.
|
||||
|
||||
Removing wildcard CORS reduces the old SPA development surface but does not
|
||||
provide authentication and does not by itself prevent cross-site HTML form
|
||||
submission. The Runtime console therefore remains documented as
|
||||
trusted-network-only. Authentication/session/CSRF design is intentionally
|
||||
separate, so this migration does not create a misleading partial security
|
||||
model.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A task, device name, or JSON value could carry HTML/script content] -> The
|
||||
shared autoescaping environment, `tojson` for structured output, no safe
|
||||
bypasses, and XSS regression tests make the protection mechanical.
|
||||
- [Templates or assets work from a checkout but not an installed wheel] ->
|
||||
Explicit setuptools package-data rules and a built-wheel smoke test verify
|
||||
deployment behavior.
|
||||
- [Removing the npm/Vite workflow disrupts an operator's existing runbook] ->
|
||||
Mark the removal as breaking, update all Runtime console documentation, and
|
||||
retain a Git-revert rollback path with no data migration.
|
||||
- [Removing CORS breaks an undiscovered browser client] -> The existing CORS
|
||||
configuration was introduced for the deleted Vite development flow; JSON
|
||||
clients outside a browser remain unaffected. A future browser integration
|
||||
must add an explicit origin policy rather than restore a wildcard.
|
||||
- [Base64 screenshots make large task-detail responses expensive] -> Preserve
|
||||
the existing bounded-task behavior and do not change timeline storage or
|
||||
transfer format in this rendering migration.
|
||||
- [The pending `web-console` change still contains a SPA decision and one
|
||||
manual task] -> Record this change as the rendering-mechanism successor;
|
||||
reconcile the older change only after the new server-rendered browser
|
||||
workflow has been manually verified.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add direct dependencies and package-data declarations, then create the
|
||||
Jinja2 environment, templates, static assets, API-local console service,
|
||||
and `/ui/` page routes while preserving existing JSON-route tests.
|
||||
2. Add focused template, page-route, mutation/PRG, fragment-refresh, and XSS
|
||||
tests. Build and install the Runtime wheel in an isolated environment to
|
||||
verify package resources are present.
|
||||
3. Remove `console/`, SPA static mounting, the static-directory environment
|
||||
variable, Vite-specific CORS, and obsolete ignore/configuration files.
|
||||
4. Update root Runtime and macOS setup documentation to direct operators to
|
||||
`/ui/`, and document the removed npm/static-directory workflow.
|
||||
5. Run formatting, linting, non-integration Runtime tests, wheel build/smoke
|
||||
checks, strict OpenSpec validation, and a browser walkthrough covering
|
||||
dashboard refresh, task replay, device mutation, and `max_steps` update.
|
||||
|
||||
Rollback is a source revert. It restores the Vue sources and static mount if
|
||||
needed and does not alter device configuration, task metadata, or timeline
|
||||
data, so no data rollback or schema migration is required.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- The remaining manual verification task in the pending `web-console` change
|
||||
must be reconciled before that older change is archived. It is not safe to
|
||||
mark it complete solely because this proposal exists; the new `/ui/`
|
||||
browser walkthrough supplies the replacement evidence after implementation.
|
||||
- Authentication and CSRF protection remain deliberately deferred. Any plan to
|
||||
expose the Runtime console beyond a trusted local network requires a
|
||||
separate threat model and change proposal.
|
||||
@@ -0,0 +1,60 @@
|
||||
## Why
|
||||
|
||||
The local Runtime console is currently a separately built Vue/Vite SPA. It
|
||||
requires a Node toolchain for development and an optional static-directory
|
||||
configuration for same-process serving, even though its data and mutations
|
||||
already live in the Runtime FastAPI process. Rendering the console with Jinja2
|
||||
will make the operator surface deploy with the Runtime itself while preserving
|
||||
the existing REST contract for programmatic clients.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a same-origin, server-rendered Runtime console under `/ui/`, with Jinja2
|
||||
pages for device status, task browsing/detail/timeline replay, device
|
||||
registration/removal, and runtime configuration.
|
||||
- Keep the existing `/console/*` JSON endpoints and make page handlers and
|
||||
JSON handlers share API-layer console operations so their observable
|
||||
registration, deletion, filtering, and configuration semantics cannot
|
||||
drift.
|
||||
- Package console templates and static assets with `device-agent-runtime`, add
|
||||
direct Jinja2 and HTML form-parsing dependencies, and render every HTML page
|
||||
through one autoescaping template environment.
|
||||
- Replace the Vue/Vite `console/` project, `RUNTIME_CONSOLE_STATIC_DIR`, and
|
||||
SPA fallback static mount with Runtime-owned templates and normal static
|
||||
assets. Remove the permissive CORS configuration that existed only for
|
||||
cross-origin Vite development.
|
||||
- **BREAKING**: the independent `console/` npm workflow and
|
||||
`RUNTIME_CONSOLE_STATIC_DIR` deployment mode are removed. Operators will
|
||||
start the Runtime API normally and open `/ui/`; JSON API paths remain
|
||||
unchanged.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `runtime-console-template-rendering`: Same-origin Jinja2-rendered Runtime
|
||||
console pages, automatic HTML escaping, form-based mutations, and packaged
|
||||
Runtime-owned web assets.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None. The existing canonical specs do not define the pending `web-console`
|
||||
SPA, and the `/console/*` JSON API contract remains unchanged.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `api/rest.py`, `api/console.py`, a new API-layer page router,
|
||||
Runtime template/static asset directories, root `pyproject.toml`, and
|
||||
console-focused tests.
|
||||
- Removed code/assets: top-level `console/` Vue/Vite sources, Node lockfile,
|
||||
Vite environment configuration, and SPA deployment wiring.
|
||||
- Documentation: Runtime startup and console guidance in `README.md`,
|
||||
`docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` change to describe
|
||||
the built-in `/ui/` console.
|
||||
- Security boundary: this change preserves the existing trusted-network,
|
||||
unauthenticated Runtime console assumption. It does not add authentication,
|
||||
authorization, or session/CSRF protection; non-trusted exposure needs a
|
||||
separate security change.
|
||||
- Architecture: all new HTTP, HTML, and template concerns remain in the outer
|
||||
`api` layer. No `core`, `driver`, `device`, `tools`, `perception`, or
|
||||
`runtime` package gains web-framework dependencies.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Runtime console pages SHALL be served by the Runtime FastAPI application
|
||||
The Runtime FastAPI application SHALL serve its operator console from
|
||||
same-origin `/ui/` routes without requiring a separately running frontend
|
||||
process, a prebuilt SPA directory, or `RUNTIME_CONSOLE_STATIC_DIR`. `GET /`
|
||||
SHALL redirect an operator to `/ui/`.
|
||||
|
||||
#### Scenario: Open the built-in console without static-directory configuration
|
||||
- **WHEN** the Runtime application starts without `RUNTIME_CONSOLE_STATIC_DIR`
|
||||
- **THEN** `GET /` redirects to `/ui/` and `GET /ui/` returns an HTML
|
||||
dashboard rendered by the Runtime process
|
||||
|
||||
#### Scenario: Navigate the operator workflows through page routes
|
||||
- **WHEN** an operator opens `/ui/tasks`, `/ui/tasks/{task_id}`, or
|
||||
`/ui/config`
|
||||
- **THEN** the Runtime returns HTML pages for task browsing, task timeline
|
||||
detail, and device/runtime configuration respectively
|
||||
|
||||
### Requirement: Runtime console HTML SHALL use one autoescaping template environment
|
||||
Every Runtime console HTML response SHALL be rendered through one
|
||||
process-wide Jinja2 environment configured to autoescape `.html` and `.xml`
|
||||
templates. Page handlers SHALL NOT construct HTML through f-strings, string
|
||||
concatenation, or post-process rendered output to bypass that environment.
|
||||
|
||||
#### Scenario: Untrusted device and task values are rendered safely
|
||||
- **WHEN** a device name, task goal, failure reason, or timeline value contains
|
||||
`<script>alert(1)</script>`
|
||||
- **THEN** the rendered console HTML contains an escaped text representation
|
||||
and contains no script element originating from that value
|
||||
|
||||
#### Scenario: A future page inherits HTML autoescaping
|
||||
- **WHEN** a future Runtime console route renders a `.html` template through
|
||||
the shared environment
|
||||
- **THEN** its interpolated values are HTML-escaped without route-specific
|
||||
escaping configuration
|
||||
|
||||
### Requirement: Runtime console pages SHALL preserve console operational workflows
|
||||
The server-rendered console SHALL let an operator inspect device status, list
|
||||
and filter tasks, inspect a task's ordered timeline including available
|
||||
screenshots, register or remove a supported device, and view or update
|
||||
`max_steps`. Successful configuration mutations SHALL use POST/Redirect/GET;
|
||||
invalid form input SHALL be re-rendered as a readable HTML error without
|
||||
applying a partial mutation.
|
||||
|
||||
#### Scenario: Browse filtered tasks and inspect a timeline
|
||||
- **WHEN** an operator selects a device or status filter and opens a known
|
||||
task
|
||||
- **THEN** the task list contains only matching tasks and the task page renders
|
||||
its timeline in step order with its screenshot when one exists
|
||||
|
||||
#### Scenario: Register a device from the configuration page
|
||||
- **WHEN** an operator submits valid supported-device form values
|
||||
- **THEN** the Runtime registers and persists the device, responds with a
|
||||
redirect to the configuration page, and the device is visible after the
|
||||
redirect
|
||||
|
||||
#### Scenario: Reject invalid configuration without partial mutation
|
||||
- **WHEN** an operator submits an unsupported driver type, malformed
|
||||
connection value, or non-positive `max_steps`
|
||||
- **THEN** the Runtime returns an HTML validation error, preserves the prior
|
||||
Runtime/configuration state, and does not perform a redirect
|
||||
|
||||
### Requirement: Dashboard live status SHALL remain server-rendered
|
||||
The dashboard SHALL retain periodic live-status refresh without restoring a
|
||||
client-side application framework. Its browser enhancement SHALL request a
|
||||
server-rendered HTML fragment and replace only the live-status region; it
|
||||
SHALL NOT rebuild console state from a JSON API response.
|
||||
|
||||
#### Scenario: Refresh dashboard status after the polling interval
|
||||
- **WHEN** the dashboard refresh enhancement runs while the Runtime is
|
||||
available
|
||||
- **THEN** it retrieves a Jinja2-rendered status fragment and updates the
|
||||
dashboard's live-status region without a full-page reload
|
||||
|
||||
### Requirement: Existing console JSON API SHALL remain compatible
|
||||
The Runtime SHALL continue to expose the existing `/console/devices`,
|
||||
`/console/tasks`, `/console/tasks/{task_id}`, `/console/tasks/{task_id}/timeline`,
|
||||
and `/console/config` JSON endpoints with their existing methods, status
|
||||
codes, payloads, filtering behavior, and persistence semantics. Page and JSON
|
||||
routes SHALL use the same API-local console operations rather than issuing
|
||||
HTTP requests to each other.
|
||||
|
||||
#### Scenario: Programmatic client reads console data after the UI migration
|
||||
- **WHEN** a client calls an existing `GET /console/*` endpoint after the
|
||||
server-rendered console is deployed
|
||||
- **THEN** it receives the same JSON response shape and status behavior as
|
||||
before the migration
|
||||
|
||||
#### Scenario: A page mutation is visible through the JSON API
|
||||
- **WHEN** an operator registers or removes a device or updates `max_steps`
|
||||
through a `/ui/` form
|
||||
- **THEN** the corresponding `/console/*` JSON endpoint reports the same
|
||||
resulting Runtime state
|
||||
|
||||
### Requirement: Runtime console templates and assets SHALL ship with the Python package
|
||||
The Runtime distribution SHALL package all console templates and static assets
|
||||
needed by `/ui/`. The Runtime console SHALL not depend on the top-level Vue/Vite
|
||||
`console/` project, Node package installation, Vite configuration, or the
|
||||
SPA-only static-directory mount. Same-origin console operation SHALL not
|
||||
require wildcard CORS configured for Vite development.
|
||||
|
||||
#### Scenario: Run the console from an installed Runtime wheel
|
||||
- **WHEN** `device-agent-runtime` is built and installed outside the source
|
||||
checkout
|
||||
- **THEN** the Runtime can serve `/ui/` and its required CSS/browser assets
|
||||
from packaged resources
|
||||
|
||||
#### Scenario: Start a Runtime after the SPA workflow is removed
|
||||
- **WHEN** an operator starts the Runtime API using the documented Python
|
||||
command
|
||||
- **THEN** the console is available at `/ui/` without `npm install`,
|
||||
`npm run build`, `VITE_API_BASE_URL`, or `RUNTIME_CONSOLE_STATIC_DIR`
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Runtime package and web-resource setup
|
||||
|
||||
- [x] 1.1 Add direct `jinja2>=3.1` and `python-multipart` Runtime dependencies, configure setuptools package-data for Runtime console templates/CSS/JavaScript, regenerate `uv.lock`, and verify `uv lock --check`.
|
||||
- [x] 1.2 Create the Runtime-owned template and static-asset layout under `api/` for the `/ui/` console, with names suitable for wheel packaging and standard static-file serving.
|
||||
- [x] 1.3 Add a package-resource smoke test that builds and installs `device-agent-runtime` outside the source checkout and confirms `/ui/` can find its templates and assets.
|
||||
|
||||
## 2. Shared API-layer console operations
|
||||
|
||||
- [x] 2.1 Refactor `api/console.py` to expose an API-local typed console service for device, task, timeline, and runtime-config reads and mutations, preserving current validation and error semantics.
|
||||
- [x] 2.2 Rewire `create_console_router()` to use the shared service and retain every existing `/console/*` method, JSON response shape, status code, filtering rule, and persistence behavior.
|
||||
- [x] 2.3 Update `api/rest.py` to construct one shared service from its existing injected Runtime state and pass it to both JSON and HTML console routers without changing lower-layer dependencies.
|
||||
|
||||
## 3. Jinja2 Runtime console routes and pages
|
||||
|
||||
- [x] 3.1 Implement `api/console_web.py` with one module-level Jinja2 `Environment`, `FileSystemLoader`, `select_autoescape(["html", "xml"])`, and a small `HTMLResponse` render helper.
|
||||
- [x] 3.2 Mount same-origin `/ui/` page routes and `/ui/assets` static resources; make `GET /` redirect to `/ui/` unconditionally.
|
||||
- [x] 3.3 Implement the shared base layout, dashboard, and Jinja-rendered live-status fragment, including the small polling enhancement that replaces only the live region.
|
||||
- [x] 3.4 Implement server-rendered task list/filter and task-detail/timeline pages, including ordered records, safe structured tool/result output, and available screenshot data URIs.
|
||||
- [x] 3.5 Implement the configuration page and POST/Redirect/GET device registration/removal and `max_steps` update handlers, with readable `400` HTML validation errors that retain submitted safe values and perform no partial mutation.
|
||||
- [x] 3.6 Port the existing console visual layout to package-owned CSS and the narrow polling script without adding a JavaScript framework or a frontend build step.
|
||||
|
||||
## 4. Retire SPA deployment wiring and update documentation
|
||||
|
||||
- [x] 4.1 Remove `RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, the SPA fallback, and the wildcard CORS middleware used only for Vite development from `api/rest.py`.
|
||||
- [x] 4.2 Delete the top-level `console/` Vue/Vite project and remove its obsolete environment, npm, and ignore-file references while leaving `cloud-console/` untouched.
|
||||
- [x] 4.3 Update `README.md`, `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` to document normal Runtime startup followed by `/ui/`, the breaking removal of the npm/static-directory workflow, and the trusted-network-only security boundary.
|
||||
- [x] 4.4 Remove stale Docker ignore/configuration entries that only described the deleted Runtime SPA, without changing Cloud Console build or deployment behavior.
|
||||
|
||||
## 5. Automated verification
|
||||
|
||||
- [x] 5.1 Keep and extend console JSON API tests to prove `/console/*` compatibility before and after page-route mutations.
|
||||
- [x] 5.2 Add template tests covering every Runtime console template, module-level autoescape configuration, XSS probes in device/task/timeline/configuration values, safe structured JSON output, and absence of unintended `|safe` bypasses.
|
||||
- [x] 5.3 Add FastAPI `TestClient` coverage for root redirect, page navigation, empty/populated dashboard state, task filters/detail/timeline, dashboard fragment refresh, static assets, form PRG success paths, and invalid-form no-mutation paths.
|
||||
- [x] 5.4 Add regression coverage that same-origin `/ui/` works without `RUNTIME_CONSOLE_STATIC_DIR` and does not depend on the removed wildcard CORS middleware.
|
||||
|
||||
## 6. Validation and migration handoff
|
||||
|
||||
- [x] 6.1 Run targeted formatting, lint, compile, and Runtime console tests, then run the repository non-integration test suite; record any pre-existing failures separately from this change.
|
||||
- [x] 6.2 Build the Runtime wheel and run the isolated package-resource smoke test after the final dependency lock update.
|
||||
- [x] 6.3 Run `openspec validate runtime-console-jinja2-templates --strict` and resolve all validation failures.
|
||||
- [ ] 6.4 Perform a browser walkthrough of dashboard live refresh, task filtering/timeline replay, valid and invalid device configuration, device removal, and `max_steps` update using a real Runtime process.
|
||||
- [ ] 6.5 After human browser verification, reconcile the remaining manual verification and superseded SPA decision in the pending `web-console` change before any archive decision; do not mark it complete from automated evidence alone.
|
||||
@@ -8,10 +8,12 @@ dependencies = [
|
||||
"Appium-Python-Client>=5.1.1",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1",
|
||||
"mcp>=1.27,<2",
|
||||
"openai>=1.0.0",
|
||||
"paddlepaddle>=3.0.0",
|
||||
"paddleocr>=3.0.0",
|
||||
"python-multipart>=0.0.20",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
@@ -53,6 +55,13 @@ include = [
|
||||
"workflow*",
|
||||
]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
api = [
|
||||
"templates/runtime_console/*.html",
|
||||
"static/runtime_console/*.css",
|
||||
"static/runtime_console/*.js",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra --import-mode=importlib"
|
||||
|
||||
@@ -80,17 +80,18 @@ def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) ->
|
||||
"task-old",
|
||||
]
|
||||
assert [
|
||||
task["id"]
|
||||
for task in client.get("/console/tasks?device_id=iphone-1").json()
|
||||
task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
|
||||
] == ["task-old"]
|
||||
assert [task["id"] for task in client.get("/console/tasks?status=running").json()] == [
|
||||
"task-new"
|
||||
]
|
||||
assert [
|
||||
task["id"] for task in client.get("/console/tasks?status=running").json()
|
||||
] == ["task-new"]
|
||||
assert client.get("/console/tasks/task-old").json()["goal"] == "open settings"
|
||||
assert client.get("/console/tasks/missing").status_code == 404
|
||||
|
||||
|
||||
def test_console_timeline_inlines_screenshot_and_handles_empty_history(tmp_path) -> None:
|
||||
def test_console_timeline_inlines_screenshot_and_handles_empty_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
client, metadata_store = _client(tmp_path, timeline=timeline)
|
||||
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
|
||||
@@ -193,3 +194,27 @@ def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> Non
|
||||
assert runner.config.max_steps == 31
|
||||
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
|
||||
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
|
||||
|
||||
|
||||
def test_console_json_reflects_page_form_mutations(tmp_path) -> None:
|
||||
"""A device registered via the /ui/ form must be visible through /console/* JSON."""
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
client, _ = _client(tmp_path, config_store=config_store)
|
||||
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={
|
||||
"name": "From Form",
|
||||
"driver_type": "wda",
|
||||
"server_url": "http://127.0.0.1:4723",
|
||||
"udid": "form-udid",
|
||||
"wda_local_port": "8100",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
json_devices = client.get("/console/devices").json()
|
||||
assert len(json_devices) == 1
|
||||
assert json_devices[0]["name"] == "From Form"
|
||||
assert json_devices[0]["connection_info"]["udid"] == "form-udid"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Smoke test that console templates and assets ship inside the Runtime wheel.
|
||||
|
||||
Builds ``device-agent-runtime`` into a temporary directory, installs it into an
|
||||
isolated venv that cannot reach the source checkout, and asserts the packaged
|
||||
``api`` package carries the Jinja2 templates and static assets needed by
|
||||
``/ui/``. This guards against setuptools package-data regressions that would
|
||||
let the console work from an editable checkout but break from a real install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, cwd: Path | None = None) -> str:
|
||||
return subprocess.check_output(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_runtime_wheel_packages_console_templates_and_assets(tmp_path: Path) -> None:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
wheel_dir = tmp_path / "wheels"
|
||||
wheel_dir.mkdir()
|
||||
_run(
|
||||
["uv", "build", "--package", "device-agent-runtime", "--wheel", "--no-sources"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
wheels = list(repo_root.glob("dist/*.whl"))
|
||||
assert wheels, "uv build did not produce a wheel"
|
||||
wheel_path = wheels[0]
|
||||
|
||||
venv_dir = tmp_path / "venv"
|
||||
venv.create(venv_dir, with_pip=True, clear=True)
|
||||
pip = str(venv_dir / "Scripts" / "pip.exe")
|
||||
if not Path(pip).exists():
|
||||
pip = str(venv_dir / "bin" / "pip")
|
||||
_run([pip, "install", str(wheel_path)], cwd=tmp_path)
|
||||
|
||||
python = str(venv_dir / "Scripts" / "python.exe")
|
||||
if not Path(python).exists():
|
||||
python = str(venv_dir / "bin" / "python")
|
||||
|
||||
probe = _run(
|
||||
[
|
||||
python,
|
||||
"-c",
|
||||
(
|
||||
"from importlib.resources import files; "
|
||||
"api_root = files('api'); "
|
||||
"templates = sorted(p.name for p in "
|
||||
"(api_root / 'templates' / 'runtime_console').iterdir()); "
|
||||
"assets = sorted(p.name for p in "
|
||||
"(api_root / 'static' / 'runtime_console').iterdir()); "
|
||||
"print(','.join(templates)); "
|
||||
"print(','.join(assets))"
|
||||
),
|
||||
],
|
||||
cwd=tmp_path,
|
||||
)
|
||||
template_names, asset_names = probe.strip().splitlines()
|
||||
assert "base.html" in template_names
|
||||
assert "dashboard.html" in template_names
|
||||
assert "config.html" in template_names
|
||||
assert "console.css" in asset_names
|
||||
assert "dashboard.js" in asset_names
|
||||
|
||||
# Clean up the build artifact so it does not leak into the working tree.
|
||||
for wheel in wheels:
|
||||
wheel.unlink()
|
||||
@@ -0,0 +1,393 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.models import Task
|
||||
from device.manager import DeviceManager
|
||||
from runtime.task import TaskRunner, TaskRunnerConfig
|
||||
from storage.artifact_store import ArtifactStore
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
from tests.fakes import PNG_10X20, FakeDriver
|
||||
|
||||
|
||||
def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.rest import create_app
|
||||
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
app = create_app(
|
||||
manager=manager or DeviceManager(),
|
||||
metadata_store=metadata_store,
|
||||
task_runner=runner,
|
||||
device_config_store=config_store
|
||||
or DeviceConfigStore(tmp_path / "device_config.sqlite3"),
|
||||
timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
|
||||
)
|
||||
return TestClient(app), metadata_store
|
||||
|
||||
|
||||
_TEMPLATE_NAMES = [
|
||||
"base.html",
|
||||
"dashboard.html",
|
||||
"_status_fragment.html",
|
||||
"tasks.html",
|
||||
"task_detail.html",
|
||||
"config.html",
|
||||
]
|
||||
|
||||
|
||||
# -- 5.2 Template tests ------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_jinja_environment_autoescapes_html_and_xml() -> None:
|
||||
from api.console_web import _ENV
|
||||
|
||||
# select_autoescape(["html", "xml"]) returns a callable used by Jinja2.
|
||||
assert callable(_ENV.autoescape)
|
||||
assert _ENV.autoescape("foo.html") is True
|
||||
assert _ENV.autoescape("foo.xml") is True
|
||||
|
||||
|
||||
def test_every_console_template_is_known_and_loadable() -> None:
|
||||
from api.console_web import _ENV
|
||||
|
||||
for name in _TEMPLATE_NAMES:
|
||||
assert _ENV.get_template(name) is not None
|
||||
|
||||
|
||||
def test_no_template_uses_safe_filter_bypass() -> None:
|
||||
template_dir = (
|
||||
Path(__file__).resolve().parent.parent / "api" / "templates" / "runtime_console"
|
||||
)
|
||||
for path in template_dir.glob("*.html"):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "| safe" not in source, f"{path.name} uses |safe bypass"
|
||||
assert "|safe" not in source, f"{path.name} uses |safe bypass"
|
||||
|
||||
|
||||
def test_dashboard_escapes_untrusted_device_name(tmp_path) -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"dev-xss",
|
||||
lambda: FakeDriver(),
|
||||
name="<script>alert(1)</script>",
|
||||
driver_type="wda",
|
||||
)
|
||||
client, _ = _client(tmp_path, manager=manager)
|
||||
|
||||
body = client.get("/ui/").text
|
||||
assert "<script>" in body
|
||||
assert "<script>alert(1)</script>" not in body
|
||||
|
||||
|
||||
def test_tasks_list_escapes_untrusted_goal(tmp_path) -> None:
|
||||
client, metadata_store = _client(tmp_path)
|
||||
metadata_store.create_task(
|
||||
Task(
|
||||
id="task-xss",
|
||||
goal="<script>alert('xss')</script>",
|
||||
device_id="dev-1",
|
||||
)
|
||||
)
|
||||
body = client.get("/ui/tasks").text
|
||||
assert "<script>" in body
|
||||
assert "<script>alert('xss')</script>" not in body
|
||||
|
||||
|
||||
def test_task_detail_escapes_failure_reason_and_structured_output(tmp_path) -> None:
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
client, metadata_store = _client(tmp_path, timeline=timeline)
|
||||
metadata_store.create_task(
|
||||
Task(
|
||||
id="task-detail",
|
||||
goal="do thing",
|
||||
device_id="dev-1",
|
||||
failure_reason="<script>alert('fail')</script>",
|
||||
)
|
||||
)
|
||||
timeline.append(
|
||||
task_id="task-detail",
|
||||
scene={"screen": {"width": 10, "height": 20}, "elements": []},
|
||||
prompt="do thing",
|
||||
tool_call={"action": "<script>", "args": {"x": 1}},
|
||||
result={"err": "</script><script>alert(1)</script>"},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
body = client.get("/ui/tasks/task-detail").text
|
||||
assert "<script>alert('fail')</script>" not in body
|
||||
assert "</script><script>" not in body
|
||||
# Structured JSON output uses tojson which escapes angle brackets.
|
||||
assert "\\u003c" in body or "<script>" in body
|
||||
|
||||
|
||||
def test_config_form_error_preserves_and_escapes_submitted_name(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={
|
||||
"name": "<script>alert(1)</script>",
|
||||
"driver_type": "bad-driver",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "<script>alert(1)</script>" not in response.text
|
||||
assert "<script>" in response.text
|
||||
|
||||
|
||||
# -- 5.3 TestClient page-route coverage --------------------------------------
|
||||
|
||||
|
||||
def test_root_redirects_to_ui(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.get("/", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/ui/"
|
||||
|
||||
|
||||
def test_dashboard_serves_html_with_empty_state(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.get("/ui/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "No devices registered" in response.text
|
||||
|
||||
|
||||
def test_dashboard_shows_populated_metrics_and_devices(tmp_path) -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"iphone-1",
|
||||
lambda: FakeDriver(),
|
||||
name="Desk iPhone",
|
||||
driver_type="wda",
|
||||
)
|
||||
client, metadata_store = _client(tmp_path, manager=manager)
|
||||
metadata_store.create_task(
|
||||
Task(id="t-running", goal="run", device_id="iphone-1", status="running")
|
||||
)
|
||||
metadata_store.create_task(
|
||||
Task(id="t-failed", goal="fail", device_id="iphone-1", status="failed")
|
||||
)
|
||||
body = client.get("/ui/").text
|
||||
assert "Desk iPhone" in body
|
||||
assert "iphone-1" in body
|
||||
|
||||
|
||||
def test_status_fragment_endpoint_returns_html_partial(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.get("/ui/_status_fragment")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Device Status" in response.text
|
||||
|
||||
|
||||
def test_tasks_page_supports_device_and_status_filters(tmp_path) -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"iphone-1",
|
||||
lambda: FakeDriver(),
|
||||
name="Desk",
|
||||
driver_type="wda",
|
||||
)
|
||||
client, metadata_store = _client(tmp_path, manager=manager)
|
||||
older = Task(
|
||||
id="task-old",
|
||||
goal="open settings",
|
||||
device_id="iphone-1",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
newer = Task(
|
||||
id="task-new",
|
||||
goal="search",
|
||||
device_id="iphone-2",
|
||||
status="running",
|
||||
created_at=datetime(2026, 1, 2, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
metadata_store.create_task(older)
|
||||
metadata_store.create_task(newer)
|
||||
|
||||
body_all = client.get("/ui/tasks").text
|
||||
assert "task-old" in body_all
|
||||
assert "task-new" in body_all
|
||||
|
||||
body_filtered = client.get("/ui/tasks?device_id=iphone-1").text
|
||||
assert "task-old" in body_filtered
|
||||
assert "task-new" not in body_filtered
|
||||
|
||||
body_status = client.get("/ui/tasks?status=running").text
|
||||
assert "task-new" in body_status
|
||||
assert "task-old" not in body_status
|
||||
|
||||
|
||||
def test_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
client, metadata_store = _client(tmp_path, timeline=timeline)
|
||||
metadata_store.create_task(
|
||||
Task(id="task-with-timeline", goal="tap search", device_id="iphone-1")
|
||||
)
|
||||
timeline.append(
|
||||
task_id="task-with-timeline",
|
||||
scene={"screen": {"width": 10, "height": 20}, "elements": []},
|
||||
prompt="tap search",
|
||||
tool_call={"action": "tap", "args": {"x": 1, "y": 2}},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
body = client.get("/ui/tasks/task-with-timeline").text
|
||||
expected_data_uri = "data:image/png;base64," + base64.b64encode(PNG_10X20).decode(
|
||||
"ascii"
|
||||
)
|
||||
assert expected_data_uri in body
|
||||
assert "tap" in body
|
||||
|
||||
|
||||
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.get("/ui/tasks/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_config_page_lists_supported_drivers_and_current_max_steps(tmp_path) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
config_store.set_setting("max_steps", 5)
|
||||
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
|
||||
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
|
||||
body = client.get("/ui/config").text
|
||||
assert "wda" in body
|
||||
assert 'value="5"' in body
|
||||
|
||||
|
||||
def test_register_device_prg_redirects_and_persists(tmp_path) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
client, _ = _client(tmp_path, config_store=config_store)
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={
|
||||
"name": "Desk iPhone",
|
||||
"driver_type": "wda",
|
||||
"server_url": "http://127.0.0.1:4723",
|
||||
"udid": "abc123",
|
||||
"wda_local_port": "8100",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"].endswith("/ui/config")
|
||||
assert len(config_store.list()) == 1
|
||||
|
||||
|
||||
def test_register_device_rejects_bad_driver_without_partial_mutation(tmp_path) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
client, _ = _client(tmp_path, config_store=config_store)
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={"name": "Bad", "driver_type": "android"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert config_store.list() == []
|
||||
assert "unsupported driver_type" in response.text
|
||||
|
||||
|
||||
def test_register_device_rejects_non_numeric_port_without_partial_mutation(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
client, _ = _client(tmp_path, config_store=config_store)
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={
|
||||
"name": "Bad Port",
|
||||
"driver_type": "wda",
|
||||
"wda_local_port": "not-a-number",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert config_store.list() == []
|
||||
assert "wda_local_port must be a number" in response.text
|
||||
|
||||
|
||||
def test_remove_device_prg_redirects_and_removes(tmp_path) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
config_store.add(
|
||||
device_id="removable-1",
|
||||
name="To Remove",
|
||||
driver_type="wda",
|
||||
connection_info={"udid": "abc"},
|
||||
)
|
||||
client, _ = _client(tmp_path, config_store=config_store)
|
||||
response = client.post(
|
||||
"/ui/config/devices/removable-1/delete",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert config_store.list() == []
|
||||
|
||||
|
||||
def test_update_max_steps_prg_redirects_and_applies(tmp_path) -> None:
|
||||
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
|
||||
client, _ = _client(tmp_path, runner=runner)
|
||||
response = client.post(
|
||||
"/ui/config/max-steps",
|
||||
data={"max_steps": "25"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert runner.config.max_steps == 25
|
||||
|
||||
|
||||
def test_update_max_steps_rejects_non_positive_without_partial_mutation(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
|
||||
config_store.set_setting("max_steps", 10)
|
||||
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
|
||||
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
|
||||
response = client.post("/ui/config/max-steps", data={"max_steps": "0"})
|
||||
assert response.status_code == 400
|
||||
assert runner.config.max_steps == 10
|
||||
assert "max_steps must be positive" in response.text
|
||||
|
||||
|
||||
def test_update_max_steps_rejects_non_integer(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.post("/ui/config/max-steps", data={"max_steps": "abc"})
|
||||
assert response.status_code == 400
|
||||
assert "max_steps must be an integer" in response.text
|
||||
|
||||
|
||||
def test_static_assets_are_served(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
assert client.get("/ui/assets/console.css").status_code == 200
|
||||
assert client.get("/ui/assets/dashboard.js").status_code == 200
|
||||
|
||||
|
||||
# -- 5.4 Regression: no SPA static dir, no wildcard CORS ---------------------
|
||||
|
||||
|
||||
def test_ui_works_without_runtime_console_static_dir(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("RUNTIME_CONSOLE_STATIC_DIR", raising=False)
|
||||
client, _ = _client(tmp_path)
|
||||
assert client.get("/ui/").status_code == 200
|
||||
|
||||
|
||||
def test_runtime_app_does_not_register_wildcard_cors(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
# A same-origin browser client must not require CORS preflight. If wildcard
|
||||
# CORS were still registered, an explicit Origin header would produce
|
||||
# access-control-allow-origin in the response; assert it is absent for an
|
||||
# arbitrary same-origin page request.
|
||||
response = client.get(
|
||||
"/ui/",
|
||||
headers={"Origin": "http://127.0.0.1:8000"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in response.headers}
|
||||
@@ -403,10 +403,12 @@ dependencies = [
|
||||
{ name = "appium-python-client" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openai" },
|
||||
{ name = "paddleocr" },
|
||||
{ name = "paddlepaddle" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -421,10 +423,12 @@ requires-dist = [
|
||||
{ name = "appium-python-client", specifier = ">=5.1.1" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "mcp", specifier = ">=1.27,<2" },
|
||||
{ name = "openai", specifier = ">=1.0.0" },
|
||||
{ name = "paddleocr", specifier = ">=3.0.0" },
|
||||
{ name = "paddlepaddle", specifier = ">=3.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user