Skip to content

Commit 6325a69

Browse files
committed
Add HTMX-powered Web UI server over stdlib HTTP
1 parent 37ca776 commit 6325a69

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

automation_file/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@
217217
TCPActionServer,
218218
start_autocontrol_socket_server,
219219
)
220+
from automation_file.server.web_ui import WebUIServer, start_web_ui
220221
from automation_file.trigger import (
221222
FileWatcher,
222223
TriggerManager,
@@ -426,6 +427,8 @@ def __getattr__(name: str) -> Any:
426427
"render_metrics",
427428
"MetricsServer",
428429
"start_metrics_server",
430+
"WebUIServer",
431+
"start_web_ui",
429432
# Triggers
430433
"FileWatcher",
431434
"TriggerManager",

automation_file/server/web_ui.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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

tests/test_web_ui.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Tests for the HTMX Web UI server."""
2+
# pylint: disable=cyclic-import
3+
4+
from __future__ import annotations
5+
6+
import urllib.request
7+
8+
import pytest
9+
10+
from automation_file.core.action_executor import executor
11+
from automation_file.core.progress import progress_registry
12+
from automation_file.server.web_ui import start_web_ui
13+
from tests._insecure_fixtures import ipv4
14+
15+
16+
def _ensure_echo_registered() -> None:
17+
if "test_webui_echo" not in executor.registry:
18+
executor.registry.register("test_webui_echo", lambda value: value)
19+
20+
21+
def _get(url: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
22+
request = urllib.request.Request(url, headers=headers or {}, method="GET")
23+
try:
24+
with urllib.request.urlopen(request, timeout=3) as resp: # nosec B310
25+
return resp.status, resp.read().decode("utf-8")
26+
except urllib.error.HTTPError as error:
27+
return error.code, error.read().decode("utf-8")
28+
29+
30+
def test_index_returns_html_with_htmx_script() -> None:
31+
_ensure_echo_registered()
32+
server = start_web_ui(host="127.0.0.1", port=0)
33+
host, port = server.server_address
34+
try:
35+
status, body = _get(f"http://{host}:{port}/")
36+
assert status == 200
37+
assert "htmx.org" in body
38+
assert 'hx-get="/ui/progress"' in body
39+
assert 'hx-get="/ui/registry"' in body
40+
assert 'hx-get="/ui/health"' in body
41+
finally:
42+
server.shutdown()
43+
44+
45+
def test_registry_fragment_lists_actions() -> None:
46+
_ensure_echo_registered()
47+
server = start_web_ui(host="127.0.0.1", port=0)
48+
host, port = server.server_address
49+
try:
50+
status, body = _get(f"http://{host}:{port}/ui/registry")
51+
assert status == 200
52+
assert "<ul>" in body or "registry empty" in body
53+
assert "test_webui_echo" in body
54+
finally:
55+
server.shutdown()
56+
57+
58+
def test_progress_fragment_reflects_registry() -> None:
59+
_ensure_echo_registered()
60+
reporter, _ = progress_registry.create("webui_probe", total=100)
61+
reporter.update(50)
62+
server = start_web_ui(host="127.0.0.1", port=0)
63+
host, port = server.server_address
64+
try:
65+
status, body = _get(f"http://{host}:{port}/ui/progress")
66+
assert status == 200
67+
assert "webui_probe" in body
68+
assert "50.0%" in body
69+
finally:
70+
progress_registry.forget("webui_probe")
71+
server.shutdown()
72+
73+
74+
def test_health_fragment_contains_registry_size() -> None:
75+
_ensure_echo_registered()
76+
server = start_web_ui(host="127.0.0.1", port=0)
77+
host, port = server.server_address
78+
try:
79+
status, body = _get(f"http://{host}:{port}/ui/health")
80+
assert status == 200
81+
assert "registry size" in body
82+
assert "alive" in body
83+
finally:
84+
server.shutdown()
85+
86+
87+
def test_rejects_non_loopback() -> None:
88+
with pytest.raises(ValueError):
89+
start_web_ui(host=ipv4(8, 8, 8, 8), port=0)
90+
91+
92+
def test_shared_secret_required_when_set() -> None:
93+
_ensure_echo_registered()
94+
server = start_web_ui(host="127.0.0.1", port=0, shared_secret="s3cr3t")
95+
host, port = server.server_address
96+
try:
97+
status, _ = _get(f"http://{host}:{port}/")
98+
assert status == 401
99+
status, body = _get(f"http://{host}:{port}/", headers={"Authorization": "Bearer s3cr3t"})
100+
assert status == 200
101+
assert "Bearer s3cr3t" in body # echoed into hx-headers for polling
102+
finally:
103+
server.shutdown()
104+
105+
106+
def test_unknown_path_returns_404() -> None:
107+
_ensure_echo_registered()
108+
server = start_web_ui(host="127.0.0.1", port=0)
109+
host, port = server.server_address
110+
try:
111+
status, _ = _get(f"http://{host}:{port}/nope")
112+
assert status == 404
113+
finally:
114+
server.shutdown()

0 commit comments

Comments
 (0)