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:
2026-07-15 08:03:13 +08:00
co-authored by Claude Opus 4.6
parent 56f3f96363
commit e00c50e703
39 changed files with 1890 additions and 2228 deletions
+151 -82
View File
@@ -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
+332
View File
@@ -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
View File
@@ -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
+518
View File
@@ -0,0 +1,518 @@
:root {
color: #202124;
background: #f6f7f9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
letter-spacing: 0;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d9dde5;
background: #ffffff;
padding: 20px 16px;
}
.brand {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand strong {
font-size: 16px;
}
.brand span {
color: #667085;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
}
.nav-button,
.icon-text-button,
.icon-button,
.task-row {
border: 1px solid #d4d9e2;
border-radius: 8px;
background: #ffffff;
color: #202124;
}
.nav-button,
.icon-text-button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 11px;
}
.nav-button {
width: 100%;
justify-content: flex-start;
}
.nav-button.active {
border-color: #2f7c67;
background: #e7f4ef;
color: #1f5f4e;
}
.workspace {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.topbar p {
margin: 4px 0 0;
color: #667085;
font-size: 13px;
}
.view-grid,
.config-layout {
display: grid;
gap: 16px;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
border: 1px solid #d9dde5;
border-radius: 8px;
background: #ffffff;
}
.metric {
padding: 14px;
}
.metric-label {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
font-size: 26px;
}
.panel {
padding: 16px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
line-height: 1.3;
}
.empty-state {
display: grid;
place-items: center;
gap: 10px;
min-height: 170px;
border: 1px dashed #c8ced8;
border-radius: 8px;
color: #667085;
text-align: center;
padding: 22px;
}
.empty-state.compact {
min-height: 88px;
}
.device-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 58px;
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px 12px;
}
.device-row strong,
.device-row span,
.task-goal,
.task-meta {
overflow-wrap: anywhere;
}
.device-row span {
display: block;
color: #667085;
font-size: 12px;
}
.row-meta {
display: flex;
align-items: center;
gap: 8px;
}
.driver-label,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
font-weight: 650;
}
.driver-label {
background: #eef0f4;
color: #444b56;
}
.status-pill.idle,
.status-pill.completed {
background: #e5f4ec;
color: #1f6b4a;
}
.status-pill.busy,
.status-pill.running {
background: #e8f1fb;
color: #275b8d;
}
.status-pill.created,
.status-pill.cancelled {
background: #f0edf8;
color: #67508f;
}
.status-pill.offline,
.status-pill.failed,
.status-pill.error {
background: #fdebea;
color: #a43c37;
}
.filters,
.form-grid,
.settings-form,
.detail-grid {
display: grid;
gap: 12px;
}
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
label {
display: grid;
gap: 6px;
color: #475467;
font-size: 12px;
font-weight: 650;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid #cbd2dc;
border-radius: 8px;
background: #ffffff;
color: #202124;
padding: 8px 10px;
}
.task-browser {
max-height: calc(100vh - 96px);
overflow: auto;
}
.task-row {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
margin-bottom: 8px;
padding: 11px;
text-align: left;
}
.task-row:hover {
border-color: #2f7c67;
box-shadow: 0 0 0 2px #d9efe8;
}
.task-goal {
font-weight: 650;
}
.task-meta {
color: #667085;
font-size: 12px;
}
.task-row .status-pill {
grid-row: 1 / span 2;
grid-column: 2;
align-self: center;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0 0 14px;
}
.detail-grid div {
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px;
}
.detail-grid dt {
margin-bottom: 4px;
color: #667085;
font-size: 12px;
}
.detail-grid dd {
margin: 0;
overflow-wrap: anywhere;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.icon-button {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-button.danger {
color: #a43c37;
}
.timeline-stage {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
gap: 14px;
}
.screenshot-frame {
display: grid;
place-items: center;
min-height: 360px;
border: 1px solid #d9dde5;
border-radius: 8px;
background: #111827;
color: #e5e7eb;
overflow: hidden;
}
.screenshot-frame img {
display: block;
width: 100%;
height: 100%;
max-height: 520px;
object-fit: contain;
}
.step-data {
display: grid;
gap: 12px;
}
.step-data h3 {
margin: 0 0 6px;
font-size: 14px;
}
pre {
max-height: 248px;
overflow: auto;
margin: 0;
border: 1px solid #e2e6ec;
border-radius: 8px;
background: #f9fafb;
padding: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.submit-button {
align-self: end;
}
.managed {
margin-top: 14px;
}
.settings-form {
grid-template-columns: minmax(140px, 240px) auto;
align-items: end;
justify-content: start;
}
.alert {
margin: 12px 0 0;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.alert.error {
background: #fdebea;
color: #a43c37;
}
.alert.success {
background: #e5f4ec;
color: #1f6b4a;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
flex-direction: row;
align-items: center;
overflow-x: auto;
}
.nav-list {
grid-auto-flow: column;
}
.timeline-stage {
grid-template-columns: 1fr;
}
.detail-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
+25
View File
@@ -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>
+46
View File
@@ -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>
+98
View File
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Config &middot; 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 &quot;{{ device_added }}&quot; 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>&times;</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 &middot; 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 }} &middot; Apex Console{% endblock %}
{% block body %}
<header class="topbar">
<div>
<h1>Task Detail</h1>
<p><a href="{{ url_for('runtime_console_tasks') }}">&larr; 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 %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Tasks &middot; 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 %}