feat(host-agent): migrate local console to Jinja2 templates with autoescape
Tests / Test failed: 4, passed: 744
Tests / Test failed: 4, passed: 744
Replace hand-written f-string + html.escape() rendering in the Host Agent
local console with a module-level Jinja2 Environment configured with
select_autoescape(["html","xml"]). XSS safety now holds by mechanism
rather than per-call discipline — every operator-controlled field
(device name, connection_info, task summary, etc.) is escaped by the
engine uniformly.
Eight templates under host_agent/web/templates/ replace the former
_chrome(), _CSS, escape(), and per-page _xxx_body() helpers: base.html
(header/nav/CSS + {% block body %}), login, dashboard (with the polling
<script> preserved byte-identically inside {% raw %}), devices, account,
history, tasks_list, and task_detail. The task-list and task-detail
templates — added by the just-landed task-execution-progress-visibility
change — were also migrated here rather than left in f-string form,
since this change removes the shared helpers they depended on.
URLs, auth/session/CSRF semantics, redirects, and /api/status JSON are
unchanged. 15 new template tests cover render-smoke, XSS probing, script
byte-identity, and no-autoescape-bypass guards. Tasks 8.1-8.6 (manual
browser verification) remain.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from html import escape as _escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jinja2
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
@@ -16,8 +16,8 @@ from host_agent.client import HostAgentEnrollmentClient
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.devices import register_local_device, unregister_local_device
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityState, HostIdentityStore
|
||||
from host_agent.local_account import LocalAccountState, LocalAccountStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.auth import (
|
||||
SessionManager,
|
||||
@@ -34,84 +34,20 @@ CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_FORM_FIELD = "csrf_token"
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
_CSS = """
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
|
||||
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
|
||||
header nav { display: inline; margin-left: 1.5rem; }
|
||||
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
|
||||
header a { color: #fff; text-decoration: none; }
|
||||
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
|
||||
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
.error { color: #b00020; }
|
||||
.notice { color: #1b5e20; }
|
||||
"""
|
||||
_ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
|
||||
def escape(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return _escape(str(value), quote=True)
|
||||
|
||||
|
||||
def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
|
||||
nav = ""
|
||||
if session is not None:
|
||||
nav = f"""
|
||||
<nav>
|
||||
<a href="/">Status</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/tasks">Tasks</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(session.csrf_token)}">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
"""
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{escape(title)}</title>
|
||||
<style>{_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>Host Agent Console</strong>
|
||||
{nav}
|
||||
</header>
|
||||
<main>
|
||||
{body_html}
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _login_page(
|
||||
*, account: LocalAccountState | None, error: str | None = None
|
||||
def _render(
|
||||
template_name: str,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
**context: Any,
|
||||
) -> HTMLResponse:
|
||||
if account is None:
|
||||
body = """
|
||||
<h1>Login</h1>
|
||||
<p>No local account exists yet. Run <code>device-host-agent setup</code>
|
||||
on this machine to create one before logging in to the console.</p>
|
||||
"""
|
||||
return HTMLResponse(_chrome("Login", body, session=None))
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
body = f"""
|
||||
<h1>Login</h1>
|
||||
{error_html}
|
||||
<form method="post" action="/login">
|
||||
<label>Username <input type="text" name="username" required></label><br>
|
||||
<label>Password <input type="password" name="password" required></label><br>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
"""
|
||||
return HTMLResponse(_chrome("Login", body, session=None))
|
||||
html = _ENV.get_template(template_name).render(**context)
|
||||
return HTMLResponse(html, status_code=status_code)
|
||||
|
||||
|
||||
def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
|
||||
@@ -125,294 +61,52 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
|
||||
return device.status
|
||||
|
||||
|
||||
def _dashboard_body(
|
||||
*,
|
||||
identity: HostIdentityState | None,
|
||||
snapshot: dict[str, Any],
|
||||
devices: list[Any],
|
||||
config: HostAgentConfig,
|
||||
) -> str:
|
||||
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
|
||||
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
|
||||
screenshot_path = record.get("screenshot_path")
|
||||
if not screenshot_path:
|
||||
return None
|
||||
path = Path(str(screenshot_path))
|
||||
if not path.exists():
|
||||
return None
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _dashboard_texts(*, snapshot: dict[str, Any]) -> dict[str, str]:
|
||||
"""Pre-compute human-readable text strings for the dashboard template."""
|
||||
heartbeat = snapshot.get("last_heartbeat")
|
||||
assignment = snapshot.get("current_assignment")
|
||||
policy = snapshot.get("host_policy")
|
||||
busy_device_id = assignment["device_id"] if assignment else None
|
||||
heartbeat_text = (
|
||||
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
|
||||
f"({heartbeat['device_count']} devices)"
|
||||
if heartbeat
|
||||
else "never"
|
||||
)
|
||||
assignment_text = (
|
||||
f"{assignment['task_id']} on {assignment['device_id']} "
|
||||
f"(started {assignment['started_at']})"
|
||||
if assignment
|
||||
else "none"
|
||||
)
|
||||
progress = snapshot.get("progress")
|
||||
progress_text = (
|
||||
f"step {progress['step_index']} — {progress['step_status']}: {progress['summary']}"
|
||||
if progress
|
||||
else ""
|
||||
)
|
||||
policy_text = (
|
||||
"revision {revision}; self-submission {self_submission}; "
|
||||
"max active tasks {max_active}; daily token budget {daily_budget}".format(
|
||||
revision=policy["revision"],
|
||||
self_submission="enabled"
|
||||
if policy["self_submission_enabled"]
|
||||
else "disabled",
|
||||
max_active=policy["max_active_tasks"] or "unlimited",
|
||||
daily_budget=policy["daily_token_budget"] or "unmetered",
|
||||
)
|
||||
if policy
|
||||
else "no Cloud policy cached"
|
||||
)
|
||||
device_rows = "".join(
|
||||
f"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
|
||||
f"<td>{escape(device.driver_type)}</td>"
|
||||
f"<td>{escape(_device_display_status(device, busy_device_id=busy_device_id))}</td></tr>"
|
||||
for device in devices
|
||||
)
|
||||
return f"""
|
||||
<h1>Status</h1>
|
||||
<section>
|
||||
<h2>Enrollment</h2>
|
||||
<p>Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}</p>
|
||||
<p>Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}</p>
|
||||
<p>Control plane: {escape(config.control_plane_url)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Heartbeat</h2>
|
||||
<p id="last-heartbeat">{escape(heartbeat_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Cloud policy</h2>
|
||||
<p id="host-policy">{escape(policy_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Current assignment</h2>
|
||||
<p id="current-assignment">{escape(assignment_text)}</p>
|
||||
<p id="current-progress">{escape(progress_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
|
||||
<tbody id="device-status-body">{device_rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<script>
|
||||
(function () {{
|
||||
function render(data) {{
|
||||
var hb = data.status.last_heartbeat;
|
||||
document.getElementById("last-heartbeat").textContent = hb
|
||||
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
|
||||
: "never";
|
||||
var current = data.status.current_assignment;
|
||||
document.getElementById("current-assignment").textContent = current
|
||||
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
|
||||
: "none";
|
||||
var progress = data.status.progress;
|
||||
document.getElementById("current-progress").textContent = progress
|
||||
? "step " + progress.step_index + " \\u2014 " + progress.step_status + ": " + progress.summary
|
||||
: "";
|
||||
var policy = data.status.host_policy;
|
||||
document.getElementById("host-policy").textContent = policy
|
||||
? "revision " + policy.revision + "; self-submission "
|
||||
+ (policy.self_submission_enabled ? "enabled" : "disabled")
|
||||
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
|
||||
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
|
||||
: "no Cloud policy cached";
|
||||
var body = document.getElementById("device-status-body");
|
||||
body.innerHTML = "";
|
||||
data.devices.forEach(function (device) {{
|
||||
var row = document.createElement("tr");
|
||||
["id", "name", "driver_type", "status"].forEach(function (key) {{
|
||||
var cell = document.createElement("td");
|
||||
cell.textContent = device[key] || "";
|
||||
row.appendChild(cell);
|
||||
}});
|
||||
body.appendChild(row);
|
||||
}});
|
||||
}}
|
||||
function poll() {{
|
||||
fetch("/api/status", {{ credentials: "same-origin" }})
|
||||
.then(function (response) {{ return response.ok ? response.json() : null; }})
|
||||
.then(function (data) {{ if (data) render(data); }})
|
||||
.catch(function () {{}});
|
||||
}}
|
||||
setInterval(poll, 5000);
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _devices_body(
|
||||
*,
|
||||
devices: list[dict[str, Any]],
|
||||
csrf_token: str,
|
||||
edit_record: dict[str, Any] | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
rows = "".join(
|
||||
f"""
|
||||
<tr>
|
||||
<td>{escape(device["device_id"])}</td>
|
||||
<td>{escape(device["name"] or "")}</td>
|
||||
<td>{escape(device["driver_type"])}</td>
|
||||
<td>{escape(device["cloud_device_id"] or "")}</td>
|
||||
<td>
|
||||
<a href="/devices?edit={escape(device["device_id"])}">Edit</a>
|
||||
<form class="inline" method="post" action="/devices/remove">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<input type="hidden" name="device_id" value="{escape(device["device_id"])}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
for device in devices
|
||||
)
|
||||
form_device_id = escape(edit_record["device_id"]) if edit_record else ""
|
||||
form_name = escape(edit_record["name"] or "") if edit_record else ""
|
||||
form_driver_type = escape(edit_record["driver_type"]) if edit_record else "wda"
|
||||
form_connection_info = (
|
||||
escape(json.dumps(edit_record["connection_info"])) if edit_record else "{}"
|
||||
)
|
||||
return f"""
|
||||
<h1>Devices</h1>
|
||||
{error_html}
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
<h2>{"Edit device" if edit_record else "Add device"}</h2>
|
||||
<form method="post" action="/devices/save">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<label>Device ID <input type="text" name="device_id" value="{form_device_id}" required></label><br>
|
||||
<label>Name <input type="text" name="name" value="{form_name}"></label><br>
|
||||
<label>Driver type <input type="text" name="driver_type" value="{form_driver_type}" required></label><br>
|
||||
<label>Connection info (JSON)<br>
|
||||
<textarea name="connection_info" rows="3" cols="50">{form_connection_info}</textarea>
|
||||
</label><br>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
|
||||
def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
|
||||
message_html = f'<p class="notice">{escape(message)}</p>' if message else ""
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
return f"""
|
||||
<h1>Account</h1>
|
||||
{message_html}
|
||||
{error_html}
|
||||
<form method="post" action="/account">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<label>Current password <input type="password" name="current_password" required></label><br>
|
||||
<label>New password <input type="password" name="new_password" required></label><br>
|
||||
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
|
||||
def _history_body(entries: list[dict[str, Any]]) -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td>{escape(entry['occurred_at'])}</td><td>{escape(entry['kind'])}</td>"
|
||||
f"<td>{escape(entry['summary'])}</td></tr>"
|
||||
for entry in entries
|
||||
)
|
||||
return f"""
|
||||
<h1>History</h1>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _inline_screenshot(record: dict[str, Any]) -> str:
|
||||
"""Return a ``<img>`` tag with base64-encoded screenshot, or empty string."""
|
||||
screenshot_path = record.get("screenshot_path")
|
||||
if not screenshot_path:
|
||||
return ""
|
||||
path = Path(str(screenshot_path))
|
||||
if not path.exists():
|
||||
return ""
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f'<img src="data:image/png;base64,{encoded}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">'
|
||||
|
||||
|
||||
def _tasks_body(tasks: list[dict[str, Any]]) -> str:
|
||||
if not tasks:
|
||||
return """
|
||||
<h1>Tasks</h1>
|
||||
<p>No tasks recorded.</p>
|
||||
"""
|
||||
rows = "".join(
|
||||
f"<tr>"
|
||||
f'<td><a href="/tasks/{escape(task["id"])}">{escape(task["id"])}</a></td>'
|
||||
f"<td>{escape(task.get('status') or '')}</td>"
|
||||
f"<td>{escape(task.get('device_id') or '')}</td>"
|
||||
f"<td>{escape(task.get('created_at') or '')}</td>"
|
||||
f"<td>{escape(task.get('updated_at') or '')}</td>"
|
||||
f"</tr>"
|
||||
for task in tasks
|
||||
)
|
||||
return f"""
|
||||
<h1>Tasks</h1>
|
||||
<table>
|
||||
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _task_detail_body(
|
||||
task: dict[str, Any], timeline_records: list[dict[str, Any]]
|
||||
) -> str:
|
||||
task_rows = "".join(
|
||||
f"<tr><td>{escape(key)}</td><td>{escape(task[key])}</td></tr>"
|
||||
for key in ("id", "goal", "device_id", "status", "created_at", "updated_at")
|
||||
if task.get(key) is not None
|
||||
)
|
||||
metadata_html = f"""
|
||||
<h1>Task {escape(task.get("id") or "")}</h1>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>{task_rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
if not timeline_records:
|
||||
timeline_html = "<h2>Timeline</h2><p>No timeline records.</p>"
|
||||
else:
|
||||
step_blocks = "".join(
|
||||
_timeline_step_html(record) for record in timeline_records
|
||||
)
|
||||
timeline_html = f"<h2>Timeline</h2>{step_blocks}"
|
||||
return metadata_html + timeline_html
|
||||
|
||||
|
||||
def _timeline_step_html(record: dict[str, Any]) -> str:
|
||||
index = record.get("index", "")
|
||||
timestamp = record.get("timestamp", "")
|
||||
tool_call = record.get("tool_call")
|
||||
result = record.get("result")
|
||||
prompt = record.get("prompt") or ""
|
||||
tool_call_text = json.dumps(tool_call, ensure_ascii=False) if tool_call else ""
|
||||
result_text = json.dumps(result, ensure_ascii=False) if result else ""
|
||||
screenshot_html = _inline_screenshot(record)
|
||||
return f"""
|
||||
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
|
||||
<p><strong>Step {escape(index)}</strong> — {escape(timestamp)}</p>
|
||||
<p>Prompt: {escape(prompt)}</p>
|
||||
<p>Tool call: <code>{escape(tool_call_text)}</code></p>
|
||||
<p>Result: <code>{escape(result_text)}</code></p>
|
||||
{screenshot_html}
|
||||
</div>
|
||||
"""
|
||||
return {
|
||||
"heartbeat_text": (
|
||||
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
|
||||
f"({heartbeat['device_count']} devices)"
|
||||
if heartbeat
|
||||
else "never"
|
||||
),
|
||||
"assignment_text": (
|
||||
f"{assignment['task_id']} on {assignment['device_id']} "
|
||||
f"(started {assignment['started_at']})"
|
||||
if assignment
|
||||
else "none"
|
||||
),
|
||||
"progress_text": (
|
||||
f"step {progress['step_index']} \u2014 {progress['step_status']}: "
|
||||
f"{progress['summary']}"
|
||||
if progress
|
||||
else ""
|
||||
),
|
||||
"policy_text": (
|
||||
f"revision {policy['revision']}; self-submission "
|
||||
f"{'enabled' if policy['self_submission_enabled'] else 'disabled'}; "
|
||||
f"max active tasks {policy['max_active_tasks'] or 'unlimited'}; "
|
||||
f"daily token budget {policy['daily_token_budget'] or 'unmetered'}"
|
||||
if policy
|
||||
else "no Cloud policy cached"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_console_app(
|
||||
@@ -464,13 +158,13 @@ def create_console_app(
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page() -> HTMLResponse:
|
||||
account = await asyncio.to_thread(local_account_store.load)
|
||||
return _login_page(account=account)
|
||||
return _render("login.html", title="Login", session=None, account=account)
|
||||
|
||||
@app.post("/login")
|
||||
async def login_submit(request: Request) -> Response:
|
||||
account = await asyncio.to_thread(local_account_store.load)
|
||||
if account is None:
|
||||
return _login_page(account=None)
|
||||
return _render("login.html", title="Login", session=None, account=None)
|
||||
form = await request.form()
|
||||
username = str(form.get("username", ""))
|
||||
password = str(form.get("password", ""))
|
||||
@@ -478,7 +172,13 @@ def create_console_app(
|
||||
attempt_login, local_account_store, username=username, password=password
|
||||
)
|
||||
if not ok:
|
||||
return _login_page(account=account, error="Invalid username or password.")
|
||||
return _render(
|
||||
"login.html",
|
||||
title="Login",
|
||||
session=None,
|
||||
account=account,
|
||||
error="Invalid username or password.",
|
||||
)
|
||||
session_token, _ = session_manager.create_session(username)
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
@@ -509,18 +209,38 @@ def create_console_app(
|
||||
) -> HTMLResponse:
|
||||
identity = await asyncio.to_thread(identity_store.load)
|
||||
snapshot = status_tracker.snapshot()
|
||||
devices = manager.list_devices()
|
||||
body = _dashboard_body(
|
||||
identity=identity, snapshot=snapshot, devices=devices, config=config
|
||||
busy_device_id = (
|
||||
snapshot["current_assignment"]["device_id"]
|
||||
if snapshot.get("current_assignment")
|
||||
else None
|
||||
)
|
||||
devices = [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"driver_type": d.driver_type,
|
||||
"display_status": _device_display_status(
|
||||
d, busy_device_id=busy_device_id
|
||||
),
|
||||
}
|
||||
for d in manager.list_devices()
|
||||
]
|
||||
texts = _dashboard_texts(snapshot=snapshot)
|
||||
return _render(
|
||||
"dashboard.html",
|
||||
title="Status",
|
||||
session=session,
|
||||
identity=identity,
|
||||
devices=devices,
|
||||
config=config,
|
||||
**texts,
|
||||
)
|
||||
return HTMLResponse(_chrome("Status", body, session=session))
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> JSONResponse:
|
||||
snapshot = status_tracker.snapshot()
|
||||
# Pull live progress from the executor if the tracker has no data yet.
|
||||
if executor is not None and snapshot.get("progress") is None:
|
||||
live = executor.latest_progress()
|
||||
if live is not None:
|
||||
@@ -553,13 +273,19 @@ def create_console_app(
|
||||
edit_record = (
|
||||
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
|
||||
)
|
||||
body = _devices_body(
|
||||
connection_info_json = (
|
||||
json.dumps(edit_record["connection_info"]) if edit_record else "{}"
|
||||
)
|
||||
return _render(
|
||||
"devices.html",
|
||||
title="Devices",
|
||||
session=session,
|
||||
devices=devices,
|
||||
csrf_token=session.csrf_token,
|
||||
edit_record=edit_record,
|
||||
connection_info_json=connection_info_json,
|
||||
error=None,
|
||||
)
|
||||
return HTMLResponse(_chrome("Devices", body, session=session))
|
||||
|
||||
@app.post("/devices/save")
|
||||
async def devices_save(
|
||||
@@ -605,14 +331,16 @@ def create_console_app(
|
||||
|
||||
if error is not None:
|
||||
devices = await asyncio.to_thread(config_store.list)
|
||||
body = _devices_body(
|
||||
return _render(
|
||||
"devices.html",
|
||||
title="Devices",
|
||||
session=session,
|
||||
devices=devices,
|
||||
csrf_token=session.csrf_token,
|
||||
edit_record=None,
|
||||
connection_info_json="{}",
|
||||
error=error,
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Devices", body, session=session), status_code=400
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(url="/devices", status_code=303)
|
||||
|
||||
@@ -633,8 +361,14 @@ def create_console_app(
|
||||
async def account_page(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
body = _account_body(csrf_token=session.csrf_token, message=None, error=None)
|
||||
return HTMLResponse(_chrome("Account", body, session=session))
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
@app.post("/account", response_class=HTMLResponse)
|
||||
async def account_submit(
|
||||
@@ -646,13 +380,14 @@ def create_console_app(
|
||||
new_password = str(form.get("new_password", ""))
|
||||
confirm_password = str(form.get("confirm_password", ""))
|
||||
if not new_password or new_password != confirm_password:
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message=None,
|
||||
error="New password and confirmation must match.",
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Account", body, session=session), status_code=400
|
||||
status_code=400,
|
||||
)
|
||||
ok = await asyncio.to_thread(
|
||||
change_password,
|
||||
@@ -661,28 +396,35 @@ def create_console_app(
|
||||
new_password=new_password,
|
||||
)
|
||||
if not ok:
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message=None,
|
||||
error="Current password is incorrect.",
|
||||
status_code=400,
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Account", body, session=session), status_code=400
|
||||
)
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message="Password updated.",
|
||||
error=None,
|
||||
)
|
||||
return HTMLResponse(_chrome("Account", body, session=session))
|
||||
|
||||
@app.get("/history", response_class=HTMLResponse)
|
||||
async def history_page(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
entries = await asyncio.to_thread(history_store.list_recent)
|
||||
body = _history_body(entries)
|
||||
return HTMLResponse(_chrome("History", body, session=session))
|
||||
return _render(
|
||||
"history.html",
|
||||
title="History",
|
||||
session=session,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
@app.get("/tasks", response_class=HTMLResponse)
|
||||
async def tasks_page(
|
||||
@@ -693,8 +435,12 @@ def create_console_app(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
tasks_list = await asyncio.to_thread(metadata_store.list_tasks)
|
||||
body = _tasks_body(tasks_list)
|
||||
return HTMLResponse(_chrome("Tasks", body, session=session))
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
title="Tasks",
|
||||
session=session,
|
||||
tasks=tasks_list,
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
async def task_detail_page(
|
||||
@@ -711,7 +457,44 @@ def create_console_app(
|
||||
timeline_records: list[dict[str, Any]] = []
|
||||
if timeline is not None:
|
||||
timeline_records = await asyncio.to_thread(timeline.read, task_id)
|
||||
body = _task_detail_body(task, timeline_records)
|
||||
return HTMLResponse(_chrome(f"Task {task_id}", body, session=session))
|
||||
task_rows = [
|
||||
(key, task[key])
|
||||
for key in (
|
||||
"id",
|
||||
"goal",
|
||||
"device_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
if task.get(key) is not None
|
||||
]
|
||||
timeline_steps = [
|
||||
{
|
||||
"index": record.get("index", ""),
|
||||
"timestamp": record.get("timestamp", ""),
|
||||
"prompt": record.get("prompt") or "",
|
||||
"tool_call_text": (
|
||||
json.dumps(record.get("tool_call"), ensure_ascii=False)
|
||||
if record.get("tool_call")
|
||||
else ""
|
||||
),
|
||||
"result_text": (
|
||||
json.dumps(record.get("result"), ensure_ascii=False)
|
||||
if record.get("result")
|
||||
else ""
|
||||
),
|
||||
"screenshot_src": _screenshot_data_uri(record),
|
||||
}
|
||||
for record in timeline_records
|
||||
]
|
||||
return _render(
|
||||
"task_detail.html",
|
||||
title=f"Task {task_id}",
|
||||
session=session,
|
||||
task=task,
|
||||
task_rows=task_rows,
|
||||
timeline_steps=timeline_steps,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user