summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui/app.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 23:11:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 23:11:36 +0200
commit35130e5528a52161630fd1c93572e1b2b7cd911b (patch)
tree50954e2da56e186e2651ad838093eb6c09c259a8 /packages/meshbay-node/src/meshbay_node/ui/app.py
parentc66ee41d8476461939c5f4e7fdc71c5d7fb4a85c (diff)
downloadmeshbay-35130e5528a52161630fd1c93572e1b2b7cd911b.tar.gz
feat(node): audit logging + local admin UI rewrite
Add SQLite audit store for legal compliance (LCEN/DSA): logs user IP, actions (handshake, file download/upload/delete, stream, chat), and timestamps. Retention: 1 year, with cleanup method. WebRTC transport now logs all user actions to the audit store with remote IP extraction from the ICE transport. Local web UI rewritten as a proper admin dashboard: - Stats cards (groups, files, peers) - Connected peers table with IP, username, group, state - Group cards with file listings and shared directory info - Audit log page with event/user filtering - Dark theme, responsive, auto-refresh - JSON API: /api/status, /api/groups, /api/peers, /api/audit, /api/config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py538
1 files changed, 407 insertions, 131 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index a63e28f..5e77ed8 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -1,138 +1,171 @@
"""
-MeshBay Node — local web UI (localhost:18000).
+MeshBay Node — local administration web UI (localhost:18000).
-Minimal FastAPI app providing:
- GET / → status page (HTML)
- GET /api/status → JSON status
- GET /api/files → JSON file list from the group index
- GET /api/config → JSON config summary (no secrets)
+FastAPI app providing:
+ - Dashboard: node status, connected peers, group overview
+ - Groups: file listing, shared directory info
+ - Peers: connected WebRTC/QUIC clients
+ - Audit log: IP + action log for legal compliance
+ - API endpoints for all data (JSON)
Served only on 127.0.0.1 — not exposed to the network.
+No authentication required (localhost only).
"""
-import asyncio
import json
import logging
-from typing import TYPE_CHECKING
+import time
+from pathlib import Path
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import HTMLResponse
from meshbay_node import __version__
-if TYPE_CHECKING:
- from meshbay_node.chat.store import ChatStore
- from meshbay_node.indexer import GroupIndex
-
log = logging.getLogger(__name__)
def create_ui_app(state: dict) -> FastAPI:
- """
- Create the UI FastAPI app.
-
- state dict is updated by the daemon and read by UI endpoints:
- state["status"] : str — "starting" | "running" | "error"
- state["group_id"] : str
- state["group_name"] : str
- state["hub_url"] : str
- state["username"] : str
- state["node_port"] : int
- state["index"] : GroupIndex | None
- state["endpoint_hint"]: str | None
- """
app = FastAPI(
- title="MeshBay Node UI",
+ title="MeshBay Node Admin",
version=__version__,
- docs_url=None, # disable Swagger on local UI
+ docs_url=None,
redoc_url=None,
)
+ # ── JSON API ─────────────────────────────────────────────────────────────
+
@app.get("/api/status")
async def api_status():
- index = state.get("index")
+ indexes = state.get("indexes", {})
+ total_files = sum(idx.count for idx in indexes.values())
+ groups_ctx = state.get("groups_ctx", {})
+ webrtc = state.get("webrtc")
return {
- "version": __version__,
- "status": state.get("status", "starting"),
- "hub_url": state.get("hub_url", ""),
- "username": state.get("username", ""),
- "group_id": state.get("group_id", ""),
- "group_name": state.get("group_name", ""),
- "node_port": state.get("node_port", 0),
+ "version": __version__,
+ "status": state.get("status", "starting"),
+ "hub_url": state.get("hub_url", ""),
+ "username": state.get("username", ""),
+ "node_port": state.get("node_port", 0),
+ "quic_port": state.get("quic_port", 0),
"endpoint_hint": state.get("endpoint_hint"),
- "file_count": index.count if index else 0,
- "index_version": index.version if index else 0,
+ "group_count": len(groups_ctx),
+ "total_files": total_files,
+ "webrtc_peers": webrtc.active_peers if webrtc else 0,
}
- @app.get("/api/files")
- async def api_files():
- index = state.get("index")
- if not index:
+ @app.get("/api/groups")
+ async def api_groups():
+ groups_ctx = state.get("groups_ctx", {})
+ config = state.get("config")
+ result = []
+ for gid, ctx in groups_ctx.items():
+ cfg = None
+ if config:
+ cfg = next((g for g in config.groups if g.id == gid), None)
+ idx = ctx.get("index")
+ result.append({
+ "id": gid,
+ "name": cfg.name if cfg else gid[:8],
+ "shared_dir": str(ctx.get("shared_root", "")),
+ "visibility": cfg.visibility if cfg else "private",
+ "file_count": idx.count if idx else 0,
+ "index_version": idx.version if idx else 0,
+ })
+ return {"groups": result}
+
+ @app.get("/api/groups/{group_id}/files")
+ async def api_group_files(group_id: str):
+ groups_ctx = state.get("groups_ctx", {})
+ ctx = groups_ctx.get(group_id)
+ if not ctx:
+ return {"files": []}
+ idx = ctx.get("index")
+ if not idx:
return {"files": []}
return {
"files": [
{
- "id": e.id[:16] + "…",
- "name": e.name,
- "path": e.path,
- "size": e.size,
- "type": e.type,
- "duration": e.duration,
+ "id": e.id,
+ "name": e.name,
+ "path": e.path,
+ "size": e.size,
+ "type": e.type,
+ "added_at": e.added_at,
}
- for e in index.entries
+ for e in idx.entries
]
}
- @app.get("/", response_class=HTMLResponse)
- async def root():
- index = state.get("index")
- status = state.get("status", "starting")
- file_count = index.count if index else 0
- status_color = {"running": "#22c55e", "error": "#ef4444"}.get(status, "#f59e0b")
+ @app.get("/api/peers")
+ async def api_peers():
+ webrtc = state.get("webrtc")
+ if not webrtc:
+ return {"peers": []}
+ peers = []
+ for pid, session in list(webrtc._sessions.items()):
+ from meshbay_node.transport.webrtc_server import _get_remote_ip
+ peers.append({
+ "peer_id": pid,
+ "user_id": session._user_id or "",
+ "username": session._username or "",
+ "group_id": session._group_id or "",
+ "remote_ip": session._remote_ip or _get_remote_ip(session._pc),
+ "state": session._pc.connectionState,
+ })
+ return {"peers": peers}
- files_html = ""
- if index:
- rows = "".join(
- f"<tr><td>{e.name}</td><td>{e.type}</td>"
- f"<td>{e.size // 1024} KB</td><td>{e.path or '/'}</td></tr>"
- for e in index.entries
- )
- files_html = f"""
- <h2>Files ({file_count})</h2>
- <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">
- <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead>
- <tbody>{rows}</tbody>
- </table>"""
+ @app.get("/api/audit")
+ async def api_audit(
+ since: float = 0,
+ limit: int = 200,
+ user_id: str | None = Query(default=None),
+ event: str | None = Query(default=None),
+ ):
+ audit = state.get("audit_store")
+ if not audit:
+ return {"entries": []}
+ entries = await audit.get_entries(
+ since=since, limit=limit, user_id=user_id, event=event)
+ return {
+ "entries": [
+ {
+ "id": e.id,
+ "timestamp": e.timestamp,
+ "user_id": e.user_id,
+ "username": e.username,
+ "ip": e.ip,
+ "event": e.event,
+ "group_id": e.group_id,
+ "detail": e.detail,
+ }
+ for e in entries
+ ]
+ }
- return f"""<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <title>MeshBay Node</title>
- <style>
- body {{ font-family: monospace; max-width: 900px; margin: 40px auto; padding: 0 20px; }}
- .badge {{ display:inline-block; padding:3px 10px; border-radius:4px;
- color:#fff; background:{status_color}; font-weight:bold; }}
- table {{ font-size: 0.9em; }}
- th {{ background: #f3f4f6; }}
- </style>
- <meta http-equiv="refresh" content="10">
-</head>
-<body>
- <h1>🔗 MeshBay Node <span class="badge">{status}</span></h1>
- <p>
- <b>Hub:</b> {state.get("hub_url", "—")} &nbsp;|&nbsp;
- <b>User:</b> {state.get("username", "—")} &nbsp;|&nbsp;
- <b>Group:</b> {state.get("group_name") or state.get("group_id") or "—"} &nbsp;|&nbsp;
- <b>Port:</b> {state.get("node_port", "—")}
- </p>
- <p><b>Endpoint:</b> {state.get("endpoint_hint") or "unknown"}</p>
- {files_html}
- <hr>
- <small>MeshBay Node v{__version__} — <a href="/api/status">JSON status</a>
- — <a href="/api/files">JSON files</a> — <a href="/chat">Chat</a></small>
-</body>
-</html>"""
+ @app.get("/api/config")
+ async def api_config():
+ config = state.get("config")
+ if not config:
+ return {}
+ return {
+ "hub_url": config.hub.url,
+ "username": config.hub.username,
+ "node_port": config.node.port,
+ "quic_port": config.node.quic_port,
+ "http_port": config.node.http_port,
+ "ui_port": config.node.ui_port,
+ "data_dir": str(config.data_dir),
+ "groups": [
+ {
+ "id": g.id,
+ "name": g.name,
+ "shared_dir": g.shared_dir,
+ "visibility": g.visibility,
+ }
+ for g in config.groups
+ ],
+ }
# ── Chat endpoints ───────────────────────────────────────────────────────
@@ -159,7 +192,6 @@ def create_ui_app(state: dict) -> FastAPI:
@app.websocket("/ws/chat")
async def chat_websocket(ws: WebSocket):
- """WebSocket for real-time chat push to the local UI."""
await ws.accept()
_chat_subscribers.append(ws)
try:
@@ -171,7 +203,6 @@ def create_ui_app(state: dict) -> FastAPI:
_chat_subscribers.remove(ws)
async def broadcast_chat_to_ui(msg: dict) -> None:
- """Push a chat message to all connected UI WebSocket clients."""
payload = json.dumps(msg)
dead = []
for ws in _chat_subscribers:
@@ -184,42 +215,287 @@ def create_ui_app(state: dict) -> FastAPI:
app.broadcast_chat = broadcast_chat_to_ui
- @app.get("/chat", response_class=HTMLResponse)
- async def chat_page():
- return f"""<!DOCTYPE html>
+ # ── HTML UI ──────────────────────────────────────────────────────────────
+
+ @app.get("/", response_class=HTMLResponse)
+ async def root():
+ return _render_page(state)
+
+ @app.get("/audit", response_class=HTMLResponse)
+ async def audit_page():
+ return _render_audit_page()
+
+ return app
+
+
+def _fmt_size(n: int) -> str:
+ if n < 1024:
+ return f"{n} B"
+ if n < 1024 * 1024:
+ return f"{n / 1024:.1f} KB"
+ if n < 1024 * 1024 * 1024:
+ return f"{n / (1024 * 1024):.1f} MB"
+ return f"{n / (1024 * 1024 * 1024):.2f} GB"
+
+
+def _render_page(state: dict) -> str:
+ status = state.get("status", "starting")
+ indexes = state.get("indexes", {})
+ groups_ctx = state.get("groups_ctx", {})
+ config = state.get("config")
+ webrtc = state.get("webrtc")
+ total_files = sum(idx.count for idx in indexes.values())
+ peer_count = webrtc.active_peers if webrtc else 0
+ status_color = {"running": "#22c55e", "error": "#ef4444"}.get(status, "#f59e0b")
+
+ # Groups section
+ groups_html = ""
+ for gid, ctx in groups_ctx.items():
+ cfg = None
+ if config:
+ cfg = next((g for g in config.groups if g.id == gid), None)
+ idx = ctx.get("index")
+ name = cfg.name if cfg else gid[:8]
+ shared = ctx.get("shared_root", "")
+ vis = cfg.visibility if cfg else "private"
+ fcount = idx.count if idx else 0
+ total_size = sum(e.size for e in idx.entries) if idx else 0
+
+ file_rows = ""
+ if idx:
+ for e in sorted(idx.entries, key=lambda x: x.name):
+ file_rows += (
+ f"<tr><td>{e.name}</td><td>{e.type}</td>"
+ f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>"
+ )
+
+ groups_html += f"""
+ <div class="card">
+ <h3>{name}
+ <span class="badge" style="background:#6366f1">{vis}</span>
+ </h3>
+ <p><b>Directory:</b> <code>{shared}</code></p>
+ <p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
+ <p class="muted">ID: {gid}</p>
+ <details><summary>File list</summary>
+ <table>
+ <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead>
+ <tbody>{file_rows}</tbody>
+ </table>
+ </details>
+ </div>"""
+
+ # Peers section
+ peers_html = ""
+ if webrtc:
+ for pid, session in list(webrtc._sessions.items()):
+ from meshbay_node.transport.webrtc_server import _get_remote_ip
+ ip = session._remote_ip or _get_remote_ip(session._pc)
+ peers_html += (
+ f"<tr><td>{session._username or session._user_id or '—'}</td>"
+ f"<td>{ip or '—'}</td>"
+ f"<td>{session._group_id[:8] if session._group_id else '—'}</td>"
+ f"<td>{session._pc.connectionState}</td></tr>"
+ )
+ if not peers_html:
+ peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>'
+
+ return f"""<!DOCTYPE html>
<html lang="en">
<head>
- <meta charset="utf-8">
- <title>MeshBay Chat</title>
- <style>
- body {{ font-family: monospace; max-width: 700px; margin: 40px auto; padding: 0 20px; }}
- #messages {{ border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: auto;
- background: #fafafa; margin-bottom: 10px; }}
- .msg {{ margin: 4px 0; }}
- .sender {{ font-weight: bold; color: #2563eb; }}
- .time {{ color: #9ca3af; font-size: 0.8em; }}
- </style>
+<meta charset="utf-8">
+<title>MeshBay Node Admin</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ :root {{
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
+ --green: #22c55e; --red: #ef4444; --yellow: #f59e0b;
+ }}
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: var(--bg); color: var(--text); }}
+ .container {{ max-width: 1000px; margin: 0 auto; padding: 20px; }}
+ h1 {{ font-size: 1.5em; margin-bottom: 20px; }}
+ h2 {{ font-size: 1.2em; margin: 24px 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }}
+ h3 {{ font-size: 1em; margin-bottom: 8px; }}
+ .badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
+ color: #fff; font-size: 0.8em; font-weight: 600; vertical-align: middle; }}
+ .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px; margin-bottom: 20px; }}
+ .stat {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
+ padding: 16px; text-align: center; }}
+ .stat .value {{ font-size: 1.8em; font-weight: 700; color: var(--accent); }}
+ .stat .label {{ font-size: 0.8em; color: var(--muted); margin-top: 4px; }}
+ .card {{ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 8px; padding: 16px; margin-bottom: 12px; }}
+ table {{ width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 8px; }}
+ th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }}
+ th {{ color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; }}
+ code {{ background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }}
+ .muted {{ color: var(--muted); font-size: 0.85em; }}
+ details summary {{ cursor: pointer; color: var(--accent); font-size: 0.85em; margin-top: 8px; }}
+ a {{ color: var(--accent); text-decoration: none; }}
+ a:hover {{ text-decoration: underline; }}
+ nav {{ display: flex; gap: 16px; margin-bottom: 20px; }}
+ nav a {{ padding: 6px 12px; border-radius: 6px; background: var(--surface);
+ border: 1px solid var(--border); }}
+ nav a:hover {{ background: var(--border); text-decoration: none; }}
+ .footer {{ margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border);
+ font-size: 0.8em; color: var(--muted); }}
+</style>
</head>
<body>
- <h1>MeshBay Chat</h1>
- <div id="messages"></div>
- <p><a href="/">Back to status</a></p>
- <script>
- const box = document.getElementById('messages');
- const ws = new WebSocket('ws://' + location.host + '/ws/chat');
- ws.onmessage = (e) => {{
- const msg = JSON.parse(e.data);
- const div = document.createElement('div');
- div.className = 'msg';
- const t = new Date(msg.timestamp * 1000).toLocaleTimeString();
- div.innerHTML = '<span class="time">' + t + '</span> '
- + '<span class="sender">' + msg.sender_id + '</span>: '
- + '(encrypted message #' + msg.iteration + ')';
- box.appendChild(div);
- box.scrollTop = box.scrollHeight;
- }};
- </script>
+<div class="container">
+ <h1>MeshBay Node <span class="badge" style="background:{status_color}">{status}</span></h1>
+
+ <nav>
+ <a href="/">Dashboard</a>
+ <a href="/audit">Audit Log</a>
+ <a href="/api/status">API</a>
+ </nav>
+
+ <div class="stats">
+ <div class="stat"><div class="value">{len(groups_ctx)}</div><div class="label">Groups</div></div>
+ <div class="stat"><div class="value">{total_files}</div><div class="label">Files</div></div>
+ <div class="stat"><div class="value">{peer_count}</div><div class="label">Connected Peers</div></div>
+ <div class="stat">
+ <div class="value" style="font-size:1em;word-break:break-all">{state.get("username", "—")}</div>
+ <div class="label">User</div>
+ </div>
+ </div>
+
+ <h2>Connected Peers</h2>
+ <table>
+ <thead><tr><th>User</th><th>IP</th><th>Group</th><th>State</th></tr></thead>
+ <tbody>{peers_html}</tbody>
+ </table>
+
+ <h2>Groups</h2>
+ {groups_html or '<p class="muted">No groups configured</p>'}
+
+ <h2>Node Configuration</h2>
+ <div class="card">
+ <p><b>Hub:</b> {state.get("hub_url", "—")}</p>
+ <p><b>QUIC port:</b> {state.get("quic_port", "—")} &mdash;
+ <b>TCP port:</b> {state.get("node_port", "—")}</p>
+ <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
+ </div>
+
+ <div class="footer">
+ MeshBay Node v{__version__} &mdash; localhost only &mdash;
+ <a href="/api/status">status</a> &middot;
+ <a href="/api/groups">groups</a> &middot;
+ <a href="/api/peers">peers</a> &middot;
+ <a href="/api/audit">audit</a> &middot;
+ <a href="/api/config">config</a>
+ &mdash; auto-refresh 10s
+ </div>
+</div>
+<script>setTimeout(()=>location.reload(), 10000);</script>
</body>
</html>"""
- return app
+
+def _render_audit_page() -> str:
+ return """<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>MeshBay Node — Audit Log</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ :root {
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: var(--bg); color: var(--text); }
+ .container { max-width: 1100px; margin: 0 auto; padding: 20px; }
+ h1 { font-size: 1.5em; margin-bottom: 16px; }
+ nav { display: flex; gap: 16px; margin-bottom: 20px; }
+ nav a { padding: 6px 12px; border-radius: 6px; background: var(--surface);
+ border: 1px solid var(--border); color: var(--accent); text-decoration: none; }
+ nav a:hover { background: var(--border); }
+ .filters { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
+ .filters select, .filters input {
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
+ padding: 6px 10px; border-radius: 6px; font-size: 0.85em;
+ }
+ table { width: 100%; border-collapse: collapse; font-size: 0.82em; }
+ th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--border); }
+ th { color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase;
+ position: sticky; top: 0; background: var(--bg); }
+ .muted { color: var(--muted); }
+ #count { margin-bottom: 10px; font-size: 0.85em; color: var(--muted); }
+</style>
+</head>
+<body>
+<div class="container">
+ <h1>Audit Log</h1>
+ <nav><a href="/">Dashboard</a><a href="/audit">Audit Log</a></nav>
+
+ <div class="filters">
+ <select id="eventFilter">
+ <option value="">All events</option>
+ <option value="handshake">handshake</option>
+ <option value="file_download">file_download</option>
+ <option value="file_upload">file_upload</option>
+ <option value="file_delete">file_delete</option>
+ <option value="stream_video">stream_video</option>
+ <option value="chat_message">chat_message</option>
+ <option value="disconnect">disconnect</option>
+ <option value="auth_failed">auth_failed</option>
+ </select>
+ <input id="userFilter" placeholder="Filter by user..." />
+ <select id="limitSelect">
+ <option value="100">100 entries</option>
+ <option value="500">500 entries</option>
+ <option value="1000">1000 entries</option>
+ </select>
+ </div>
+
+ <div id="count"></div>
+ <table>
+ <thead><tr><th>Time</th><th>Event</th><th>User</th><th>IP</th><th>Group</th><th>Detail</th></tr></thead>
+ <tbody id="tbody"></tbody>
+ </table>
+</div>
+<script>
+async function load() {
+ const ev = document.getElementById('eventFilter').value;
+ const limit = document.getElementById('limitSelect').value;
+ let url = '/api/audit?limit=' + limit;
+ if (ev) url += '&event=' + ev;
+ const r = await fetch(url);
+ const data = await r.json();
+ const tbody = document.getElementById('tbody');
+ document.getElementById('count').textContent = data.entries.length + ' entries';
+ tbody.innerHTML = data.entries.map(e => {
+ const t = new Date(e.timestamp * 1000).toLocaleString();
+ return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>'
+ + (e.username || e.user_id.slice(0,8)) + '</td><td>'
+ + (e.ip || '—') + '</td><td>'
+ + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>'
+ + (e.detail || '') + '</td></tr>';
+ }).join('');
+}
+document.getElementById('eventFilter').onchange = load;
+document.getElementById('limitSelect').onchange = load;
+let debounceTimer;
+document.getElementById('userFilter').oninput = function() {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => {
+ const val = this.value;
+ const rows = document.querySelectorAll('#tbody tr');
+ rows.forEach(r => {
+ r.style.display = r.textContent.toLowerCase().includes(val.toLowerCase()) ? '' : 'none';
+ });
+ }, 200);
+};
+load();
+setInterval(load, 15000);
+</script>
+</body>
+</html>"""