Tests / Test failed: 2, passed: 693
DeviceManager marks a device "busy" as soon as an Appium/WDA session is connected, which is unrelated to whether a task is currently executing on it. The Host Agent's local console displayed this raw status, making connected-but-idle devices look permanently busy. Cross-reference the device id against AgentStatusTracker's current_assignment (already tracked via mark_assignment_started/finished) to show "connected" unless a task is actually running on that device.
574 lines
21 KiB
Python
574 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from html import escape as _escape
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
|
|
from device.manager import DeviceManager
|
|
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.status import AgentStatusTracker
|
|
from host_agent.web.auth import (
|
|
SessionManager,
|
|
SessionState,
|
|
attempt_login,
|
|
change_password,
|
|
)
|
|
from storage.device_config import DeviceConfigStore
|
|
|
|
SESSION_COOKIE_NAME = "host_console_session"
|
|
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; }
|
|
"""
|
|
|
|
|
|
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="/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
|
|
) -> 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))
|
|
|
|
|
|
def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
|
|
"""Connected-but-idle devices report "busy" at the DeviceManager layer
|
|
(an active Appium/WDA session is required to reuse it), which is not the
|
|
same as a task currently running on that device. Only the device actually
|
|
bound to the current assignment should read as "busy" here.
|
|
"""
|
|
if device.status == "busy" and device.id != busy_device_id:
|
|
return "connected"
|
|
return device.status
|
|
|
|
|
|
def _dashboard_body(
|
|
*,
|
|
identity: HostIdentityState | None,
|
|
snapshot: dict[str, Any],
|
|
devices: list[Any],
|
|
config: HostAgentConfig,
|
|
) -> str:
|
|
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"
|
|
)
|
|
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>
|
|
</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 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 create_console_app(
|
|
*,
|
|
config: HostAgentConfig,
|
|
manager: DeviceManager,
|
|
config_store: DeviceConfigStore,
|
|
local_account_store: LocalAccountStore,
|
|
identity_store: HostIdentityStore,
|
|
history_store: ConsoleHistoryStore,
|
|
status_tracker: AgentStatusTracker,
|
|
session_manager: SessionManager,
|
|
enrollment_client: HostAgentEnrollmentClient | None,
|
|
) -> FastAPI:
|
|
app = FastAPI(title="Host Agent Console")
|
|
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
|
|
|
def _session_token(request: Request) -> str | None:
|
|
return request.cookies.get(SESSION_COOKIE_NAME)
|
|
|
|
def require_session(request: Request) -> SessionState:
|
|
token = _session_token(request)
|
|
state = session_manager.validate(token) if token else None
|
|
if state is None:
|
|
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
|
return state
|
|
|
|
async def require_csrf(
|
|
request: Request,
|
|
session: SessionState = Depends(require_session),
|
|
) -> SessionState:
|
|
provided = request.headers.get(CSRF_HEADER_NAME)
|
|
if not provided:
|
|
form = await request.form()
|
|
raw = form.get(CSRF_FORM_FIELD)
|
|
provided = str(raw) if raw is not None else None
|
|
token = _session_token(request)
|
|
if (
|
|
not token
|
|
or not provided
|
|
or not session_manager.validate_csrf(token, provided)
|
|
):
|
|
raise HTTPException(status_code=403, detail="invalid CSRF token")
|
|
return session
|
|
|
|
@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)
|
|
|
|
@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)
|
|
form = await request.form()
|
|
username = str(form.get("username", ""))
|
|
password = str(form.get("password", ""))
|
|
ok = await asyncio.to_thread(
|
|
attempt_login, local_account_store, username=username, password=password
|
|
)
|
|
if not ok:
|
|
return _login_page(account=account, error="Invalid username or password.")
|
|
session_token, _ = session_manager.create_session(username)
|
|
response = RedirectResponse(url="/", status_code=303)
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=session_token,
|
|
httponly=True,
|
|
samesite="strict",
|
|
secure=cookie_secure,
|
|
path="/",
|
|
)
|
|
return response
|
|
|
|
@app.post("/logout")
|
|
async def logout(
|
|
request: Request,
|
|
session: SessionState = Depends(require_csrf),
|
|
) -> Response:
|
|
token = _session_token(request)
|
|
if token:
|
|
session_manager.invalidate(token)
|
|
response = RedirectResponse(url="/login", status_code=303)
|
|
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
|
|
return response
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def dashboard(
|
|
session: SessionState = Depends(require_session),
|
|
) -> 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
|
|
)
|
|
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()
|
|
current_assignment = snapshot.get("current_assignment")
|
|
busy_device_id = current_assignment["device_id"] if current_assignment else None
|
|
devices = [
|
|
{
|
|
"id": device.id,
|
|
"name": device.name,
|
|
"driver_type": device.driver_type,
|
|
"status": _device_display_status(device, busy_device_id=busy_device_id),
|
|
}
|
|
for device in manager.list_devices()
|
|
]
|
|
return JSONResponse({"status": snapshot, "devices": devices})
|
|
|
|
@app.get("/devices", response_class=HTMLResponse)
|
|
async def devices_page(
|
|
request: Request,
|
|
session: SessionState = Depends(require_session),
|
|
) -> HTMLResponse:
|
|
devices = await asyncio.to_thread(config_store.list)
|
|
edit_id = request.query_params.get("edit")
|
|
edit_record = (
|
|
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
|
|
)
|
|
body = _devices_body(
|
|
devices=devices,
|
|
csrf_token=session.csrf_token,
|
|
edit_record=edit_record,
|
|
error=None,
|
|
)
|
|
return HTMLResponse(_chrome("Devices", body, session=session))
|
|
|
|
@app.post("/devices/save")
|
|
async def devices_save(
|
|
request: Request,
|
|
session: SessionState = Depends(require_csrf),
|
|
) -> Response:
|
|
form = await request.form()
|
|
device_id = str(form.get("device_id", "")).strip()
|
|
driver_type = str(form.get("driver_type", "")).strip()
|
|
name = str(form.get("name", "")).strip() or None
|
|
connection_info_raw = str(form.get("connection_info", "") or "{}")
|
|
|
|
error: str | None = None
|
|
connection_info: dict[str, Any] = {}
|
|
if not device_id or not driver_type:
|
|
error = "Device ID and driver type are required."
|
|
else:
|
|
try:
|
|
parsed = json.loads(connection_info_raw)
|
|
except ValueError:
|
|
error = "Connection info must be valid JSON."
|
|
else:
|
|
if not isinstance(parsed, dict):
|
|
error = "Connection info must be a JSON object."
|
|
else:
|
|
connection_info = parsed
|
|
|
|
if error is None:
|
|
try:
|
|
await asyncio.to_thread(
|
|
register_local_device,
|
|
config_store,
|
|
manager,
|
|
device_id=device_id,
|
|
driver_type=driver_type,
|
|
connection_info=connection_info,
|
|
name=name,
|
|
config=config,
|
|
enrollment_client=enrollment_client,
|
|
)
|
|
except ValueError as exc:
|
|
error = str(exc)
|
|
|
|
if error is not None:
|
|
devices = await asyncio.to_thread(config_store.list)
|
|
body = _devices_body(
|
|
devices=devices,
|
|
csrf_token=session.csrf_token,
|
|
edit_record=None,
|
|
error=error,
|
|
)
|
|
return HTMLResponse(
|
|
_chrome("Devices", body, session=session), status_code=400
|
|
)
|
|
return RedirectResponse(url="/devices", status_code=303)
|
|
|
|
@app.post("/devices/remove")
|
|
async def devices_remove(
|
|
request: Request,
|
|
session: SessionState = Depends(require_csrf),
|
|
) -> Response:
|
|
form = await request.form()
|
|
device_id = str(form.get("device_id", "")).strip()
|
|
if device_id:
|
|
await asyncio.to_thread(
|
|
unregister_local_device, config_store, manager, device_id=device_id
|
|
)
|
|
return RedirectResponse(url="/devices", status_code=303)
|
|
|
|
@app.get("/account", response_class=HTMLResponse)
|
|
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))
|
|
|
|
@app.post("/account", response_class=HTMLResponse)
|
|
async def account_submit(
|
|
request: Request,
|
|
session: SessionState = Depends(require_csrf),
|
|
) -> HTMLResponse:
|
|
form = await request.form()
|
|
current_password = str(form.get("current_password", ""))
|
|
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(
|
|
csrf_token=session.csrf_token,
|
|
message=None,
|
|
error="New password and confirmation must match.",
|
|
)
|
|
return HTMLResponse(
|
|
_chrome("Account", body, session=session), status_code=400
|
|
)
|
|
ok = await asyncio.to_thread(
|
|
change_password,
|
|
local_account_store,
|
|
current_password=current_password,
|
|
new_password=new_password,
|
|
)
|
|
if not ok:
|
|
body = _account_body(
|
|
csrf_token=session.csrf_token,
|
|
message=None,
|
|
error="Current password is incorrect.",
|
|
)
|
|
return HTMLResponse(
|
|
_chrome("Account", body, session=session), status_code=400
|
|
)
|
|
body = _account_body(
|
|
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 app
|