|
| 1 | +"""Read-only observability Web UI (stdlib + HTMX). |
| 2 | +
|
| 3 | +Serves a single HTML page that polls three HTML fragments — registered |
| 4 | +actions, live progress, and health summary — using HTMX (loaded from a |
| 5 | +pinned CDN URL). Write operations are deliberately out of scope; trigger |
| 6 | +actions through :mod:`http_server` / :mod:`tcp_server` with their auth |
| 7 | +story intact. |
| 8 | +
|
| 9 | +Loopback-only by default; ``allow_non_loopback=True`` is required to bind |
| 10 | +elsewhere. When ``shared_secret`` is supplied every request must carry |
| 11 | +``Authorization: Bearer <secret>`` — the rendered HTML includes a |
| 12 | +``hx-headers`` attribute so HTMX's polled requests carry the token. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import hmac |
| 18 | +import html as html_lib |
| 19 | +import json |
| 20 | +import threading |
| 21 | +import time |
| 22 | +from http import HTTPStatus |
| 23 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 24 | + |
| 25 | +from automation_file.core.action_executor import executor |
| 26 | +from automation_file.core.progress import progress_registry |
| 27 | +from automation_file.logging_config import file_automation_logger |
| 28 | +from automation_file.server.network_guards import ensure_loopback |
| 29 | + |
| 30 | +_DEFAULT_HOST = "127.0.0.1" |
| 31 | +_DEFAULT_PORT = 9955 |
| 32 | +_HTMX_CDN = "https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" |
| 33 | +_HTMX_SRI = "sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" |
| 34 | + |
| 35 | +_INDEX_TEMPLATE = """<!doctype html> |
| 36 | +<html lang="en"> |
| 37 | +<head> |
| 38 | +<meta charset="utf-8"> |
| 39 | +<title>automation_file</title> |
| 40 | +<script src="{htmx_src}" integrity="{htmx_sri}" crossorigin="anonymous"></script> |
| 41 | +<style> |
| 42 | + body {{ font-family: system-ui, sans-serif; margin: 2rem; color: #1d1f21; }} |
| 43 | + h1 {{ font-size: 1.4rem; margin-bottom: 0.2rem; }} |
| 44 | + h2 {{ font-size: 1.05rem; margin-top: 1.5rem; color: #555; }} |
| 45 | + table {{ border-collapse: collapse; width: 100%; font-size: 0.9rem; }} |
| 46 | + th, td {{ padding: 0.35rem 0.6rem; border-bottom: 1px solid #eee; text-align: left; }} |
| 47 | + th {{ background: #f5f5f5; }} |
| 48 | + .muted {{ color: #888; }} |
| 49 | + code {{ background: #f3f3f3; padding: 0.1rem 0.3rem; border-radius: 3px; }} |
| 50 | +</style> |
| 51 | +</head> |
| 52 | +<body hx-headers='{auth_headers}'> |
| 53 | +<h1>automation_file</h1> |
| 54 | +<p class="muted">Read-only dashboard. Write operations live on the action server.</p> |
| 55 | +
|
| 56 | +<h2>Health</h2> |
| 57 | +<div id="health" hx-get="/ui/health" hx-trigger="load, every 5s" hx-swap="innerHTML"> |
| 58 | + <em class="muted">loading…</em> |
| 59 | +</div> |
| 60 | +
|
| 61 | +<h2>Progress</h2> |
| 62 | +<div id="progress" hx-get="/ui/progress" hx-trigger="load, every 2s" hx-swap="innerHTML"> |
| 63 | + <em class="muted">loading…</em> |
| 64 | +</div> |
| 65 | +
|
| 66 | +<h2>Registered actions</h2> |
| 67 | +<div id="registry" hx-get="/ui/registry" hx-trigger="load, every 30s" hx-swap="innerHTML"> |
| 68 | + <em class="muted">loading…</em> |
| 69 | +</div> |
| 70 | +</body> |
| 71 | +</html> |
| 72 | +""" |
| 73 | + |
| 74 | + |
| 75 | +class _WebUIHandler(BaseHTTPRequestHandler): |
| 76 | + """Serves the dashboard page plus its three HTMX fragment endpoints.""" |
| 77 | + |
| 78 | + def log_message( # pylint: disable=arguments-differ |
| 79 | + self, format_str: str, *args: object |
| 80 | + ) -> None: |
| 81 | + file_automation_logger.info("web_ui: " + format_str, *args) |
| 82 | + |
| 83 | + def do_GET(self) -> None: # pylint: disable=invalid-name |
| 84 | + if not self._authorized(): |
| 85 | + self._send_html(HTTPStatus.UNAUTHORIZED, "<p>unauthorized</p>") |
| 86 | + return |
| 87 | + path = self.path.split("?", 1)[0] |
| 88 | + if path in ("/", "/index.html"): |
| 89 | + self._send_html(HTTPStatus.OK, self._render_index()) |
| 90 | + return |
| 91 | + if path == "/ui/health": |
| 92 | + self._send_html(HTTPStatus.OK, _render_health()) |
| 93 | + return |
| 94 | + if path == "/ui/progress": |
| 95 | + self._send_html(HTTPStatus.OK, _render_progress()) |
| 96 | + return |
| 97 | + if path == "/ui/registry": |
| 98 | + self._send_html(HTTPStatus.OK, _render_registry()) |
| 99 | + return |
| 100 | + self._send_html(HTTPStatus.NOT_FOUND, "<p>not found</p>") |
| 101 | + |
| 102 | + def _authorized(self) -> bool: |
| 103 | + secret: str | None = getattr(self.server, "shared_secret", None) |
| 104 | + if not secret: |
| 105 | + return True |
| 106 | + header = self.headers.get("Authorization", "") |
| 107 | + if not header.startswith("Bearer "): |
| 108 | + return False |
| 109 | + return hmac.compare_digest(header[len("Bearer ") :], secret) |
| 110 | + |
| 111 | + def _send_html(self, status: HTTPStatus, body: str) -> None: |
| 112 | + payload = body.encode("utf-8") |
| 113 | + self.send_response(status) |
| 114 | + self.send_header("Content-Type", "text/html; charset=utf-8") |
| 115 | + self.send_header("Content-Length", str(len(payload))) |
| 116 | + self.send_header("Cache-Control", "no-store") |
| 117 | + self.end_headers() |
| 118 | + self.wfile.write(payload) |
| 119 | + |
| 120 | + def _render_index(self) -> str: |
| 121 | + secret: str | None = getattr(self.server, "shared_secret", None) |
| 122 | + auth_headers_obj = {"Authorization": f"Bearer {secret}"} if secret else {} |
| 123 | + auth_headers = html_lib.escape(json.dumps(auth_headers_obj), quote=True) |
| 124 | + return _INDEX_TEMPLATE.format( |
| 125 | + htmx_src=_HTMX_CDN, |
| 126 | + htmx_sri=_HTMX_SRI, |
| 127 | + auth_headers=auth_headers, |
| 128 | + ) |
| 129 | + |
| 130 | + |
| 131 | +def _render_health() -> str: |
| 132 | + names = list(executor.registry.event_dict.keys()) |
| 133 | + return ( |
| 134 | + "<table>" |
| 135 | + "<tr><th>process</th><td>alive</td></tr>" |
| 136 | + f"<tr><th>registry size</th><td>{len(names)}</td></tr>" |
| 137 | + f"<tr><th>time</th><td>{html_lib.escape(time.strftime('%Y-%m-%d %H:%M:%S'))}</td></tr>" |
| 138 | + "</table>" |
| 139 | + ) |
| 140 | + |
| 141 | + |
| 142 | +def _render_progress() -> str: |
| 143 | + snapshots = progress_registry.list() |
| 144 | + if not snapshots: |
| 145 | + return "<p class='muted'>no active transfers</p>" |
| 146 | + rows = [] |
| 147 | + for item in snapshots: |
| 148 | + name = html_lib.escape(str(item.get("name", ""))) |
| 149 | + status = html_lib.escape(str(item.get("status", ""))) |
| 150 | + transferred = int(item.get("transferred", 0) or 0) |
| 151 | + total = item.get("total") |
| 152 | + total_cell = "—" if total in (None, 0) else str(total) |
| 153 | + pct = "" |
| 154 | + if isinstance(total, int) and total > 0: |
| 155 | + pct = f" ({(transferred / total) * 100:.1f}%)" |
| 156 | + rows.append( |
| 157 | + "<tr>" |
| 158 | + f"<td><code>{name}</code></td>" |
| 159 | + f"<td>{status}</td>" |
| 160 | + f"<td>{transferred}{pct}</td>" |
| 161 | + f"<td>{total_cell}</td>" |
| 162 | + "</tr>" |
| 163 | + ) |
| 164 | + return ( |
| 165 | + "<table>" |
| 166 | + "<tr><th>name</th><th>status</th><th>transferred</th><th>total</th></tr>" |
| 167 | + + "".join(rows) |
| 168 | + + "</table>" |
| 169 | + ) |
| 170 | + |
| 171 | + |
| 172 | +def _render_registry() -> str: |
| 173 | + names = sorted(executor.registry.event_dict.keys()) |
| 174 | + if not names: |
| 175 | + return "<p class='muted'>registry empty</p>" |
| 176 | + items = "".join(f"<li><code>{html_lib.escape(name)}</code></li>" for name in names) |
| 177 | + return f"<ul>{items}</ul>" |
| 178 | + |
| 179 | + |
| 180 | +class WebUIServer(ThreadingHTTPServer): |
| 181 | + """Threaded HTTP server for the HTMX dashboard.""" |
| 182 | + |
| 183 | + def __init__( |
| 184 | + self, |
| 185 | + server_address: tuple[str, int], |
| 186 | + handler_class: type = _WebUIHandler, |
| 187 | + shared_secret: str | None = None, |
| 188 | + ) -> None: |
| 189 | + super().__init__(server_address, handler_class) |
| 190 | + self.shared_secret: str | None = shared_secret |
| 191 | + |
| 192 | + |
| 193 | +def start_web_ui( |
| 194 | + host: str = _DEFAULT_HOST, |
| 195 | + port: int = _DEFAULT_PORT, |
| 196 | + allow_non_loopback: bool = False, |
| 197 | + shared_secret: str | None = None, |
| 198 | +) -> WebUIServer: |
| 199 | + """Start the Web UI server on a background thread.""" |
| 200 | + if not allow_non_loopback: |
| 201 | + ensure_loopback(host) |
| 202 | + if allow_non_loopback and not shared_secret: |
| 203 | + file_automation_logger.warning( |
| 204 | + "web_ui: non-loopback bind without shared_secret is insecure", |
| 205 | + ) |
| 206 | + server = WebUIServer((host, port), shared_secret=shared_secret) |
| 207 | + thread = threading.Thread(target=server.serve_forever, daemon=True) |
| 208 | + thread.start() |
| 209 | + file_automation_logger.info( |
| 210 | + "web_ui: listening on %s:%d (auth=%s)", |
| 211 | + host, |
| 212 | + port, |
| 213 | + "on" if shared_secret else "off", |
| 214 | + ) |
| 215 | + return server |
0 commit comments