Files
darkproxy/monitor/status_dashboard.py
auto-ci 21718042b6
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
Harden release defaults and validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 19:29:00 -04:00

881 lines
30 KiB
Python

import http.client
import json
import os
import random
import re
import socket
import struct
import threading
import time
import urllib.parse
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "8080"))
DOCKER_SOCKET = os.getenv("DOCKER_SOCKET", "/var/run/docker.sock")
DOCKER_PROJECT = os.getenv("DOCKER_PROJECT", "darkproxy")
DNS_SERVER = os.getenv("DNS_SERVER", "darkpihole")
DNS_PORT = int(os.getenv("DNS_PORT", "53"))
PROXY_HOST = os.getenv("PROXY_HOST", "dark3proxy")
PROXY_PORT = int(os.getenv("PROXY_PORT", "1080"))
PROXY_USERS_FILE = os.getenv("PROXY_USERS_FILE", "/run/secrets/PROXY_USERS")
SOCKET_TIMEOUT = float(os.getenv("SOCKET_TIMEOUT", "5"))
STATUS_CACHE_SECONDS = int(os.getenv("STATUS_CACHE_SECONDS", "15"))
LOG_TAIL_LINES = int(os.getenv("LOG_TAIL_LINES", "120"))
ERROR_RE = re.compile(r"error|failed|not found|panic|traceback|unhealthy|refused|parse", re.IGNORECASE)
IGNORE_RE = {
"darkscale": re.compile(r"tpmrm0|Tailscale is stopped", re.IGNORECASE),
"darkpihole": re.compile(r"refused to do a recursive query", re.IGNORECASE),
"darki2p": re.compile(
r"SessionCreated read error: End of file|"
r"Connect error Operation canceled|"
r"Connect error Network is unreachable|"
r"RouterInfo for .* not found|"
r"RouterInfo not found, failed to send messages|"
r"NetDbReq: .* not found after 5 attempts",
re.IGNORECASE,
),
}
RCODE_NAMES = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
}
DNS_CHECKS = [
{
"name": "google.com",
"record_type": "A",
"severity": "critical",
"label": "Public DNS path",
"note": "Pi-hole -> CoreDNS -> Unbound",
},
{
"name": "facebookcorewwwi.onion",
"record_type": "A",
"severity": "critical",
"label": ".onion resolution",
"note": "Tor DNS path",
},
{
"name": "stats.i2p",
"record_type": "A",
"severity": "critical",
"label": ".i2p resolution",
"note": "i2pdns bridge path",
},
{
"name": "vitalik.eth",
"record_type": "TXT",
"severity": "warning",
"label": ".eth resolution",
"note": "ENS resolver path",
},
{
"name": "brad.zil",
"record_type": "TXT",
"severity": "warning",
"label": ".zil resolution",
"note": "Zilliqa resolver path",
},
{
"name": "d.bit",
"record_type": "TXT",
"severity": "info",
"label": ".bit resolution",
"note": "Sync-dependent Namecoin path",
},
]
STATUS_CACHE = {"expires_at": 0.0, "payload": None}
CACHE_LOCK = threading.Lock()
class UnixSocketHTTPConnection(http.client.HTTPConnection):
def __init__(self, socket_path: str, timeout: float):
super().__init__("localhost", timeout=timeout)
self.socket_path = socket_path
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(self.socket_path)
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def docker_request(path: str) -> tuple[int, bytes]:
conn = UnixSocketHTTPConnection(DOCKER_SOCKET, timeout=SOCKET_TIMEOUT)
try:
conn.request("GET", path)
response = conn.getresponse()
return response.status, response.read()
finally:
conn.close()
def docker_json(path: str):
status, payload = docker_request(path)
if status >= 400:
raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
return json.loads(payload.decode("utf-8"))
def docker_bytes(path: str) -> bytes:
status, payload = docker_request(path)
if status >= 400:
raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
return payload
def decode_docker_stream(payload: bytes) -> str:
# Docker may frame stdout/stderr for non-TTY containers using an 8-byte header.
chunks = []
offset = 0
while offset + 8 <= len(payload):
if payload[offset + 1 : offset + 4] != b"\x00\x00\x00":
return payload.decode("utf-8", errors="replace")
frame_len = struct.unpack(">I", payload[offset + 4 : offset + 8])[0]
frame_start = offset + 8
frame_end = frame_start + frame_len
if frame_end > len(payload):
return payload.decode("utf-8", errors="replace")
chunks.append(payload[frame_start:frame_end])
offset = frame_end
if offset == len(payload):
return b"".join(chunks).decode("utf-8", errors="replace")
return payload.decode("utf-8", errors="replace")
def service_log_text(container_id: str, tail: int) -> str:
query = urllib.parse.urlencode({"stdout": 1, "stderr": 1, "tail": max(1, min(tail, 500))})
payload = docker_bytes(f"/containers/{container_id}/logs?{query}")
return decode_docker_stream(payload).strip()
def extract_error_excerpt(name: str, log_text: str) -> list[str]:
ignore = IGNORE_RE.get(name)
matches = []
for line in log_text.splitlines():
if not ERROR_RE.search(line):
continue
if ignore and ignore.search(line):
continue
matches.append(line)
return matches[-6:]
def read_proxy_users() -> list[dict[str, str]]:
users = []
try:
with open(PROXY_USERS_FILE, "r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
username, password = line.split(":", 1)
if username:
users.append({"username": username, "password": password})
except FileNotFoundError:
return []
return users
def encode_dns_name(name: str) -> bytes:
parts = name.rstrip(".").split(".")
encoded = bytearray()
for part in parts:
label = part.encode("idna")
encoded.append(len(label))
encoded.extend(label)
encoded.append(0)
return bytes(encoded)
def read_dns_name(payload: bytes, offset: int) -> tuple[str, int]:
labels = []
jumped = False
next_offset = offset
while True:
length = payload[offset]
if length == 0:
offset += 1
if not jumped:
next_offset = offset
break
if length & 0xC0 == 0xC0:
pointer = struct.unpack(">H", payload[offset : offset + 2])[0] & 0x3FFF
offset = pointer
if not jumped:
next_offset += 2
jumped = True
continue
offset += 1
labels.append(payload[offset : offset + length].decode("utf-8", errors="replace"))
offset += length
if not jumped:
next_offset = offset
return ".".join(labels), next_offset
def parse_dns_answers(payload: bytes, qtype: int) -> tuple[str, list[str]]:
_, _, _, answer_count, _, _ = struct.unpack(">HHHHHH", payload[:12])
offset = 12
for _ in range(1):
_, offset = read_dns_name(payload, offset)
offset += 4
answers = []
for _ in range(answer_count):
_, offset = read_dns_name(payload, offset)
record_type, _, _, rdlength = struct.unpack(">HHIH", payload[offset : offset + 10])
offset += 10
rdata = payload[offset : offset + rdlength]
offset += rdlength
if record_type == 1 and qtype == 1 and rdlength == 4:
answers.append(socket.inet_ntoa(rdata))
elif record_type == 16 and qtype == 16 and rdlength > 0:
strings = []
inner_offset = 0
while inner_offset < len(rdata):
chunk_len = rdata[inner_offset]
inner_offset += 1
strings.append(rdata[inner_offset : inner_offset + chunk_len].decode("utf-8", errors="replace"))
inner_offset += chunk_len
answers.append("".join(strings))
rcode = payload[3] & 0x0F
return RCODE_NAMES.get(rcode, f"RCODE_{rcode}"), answers
def dns_query(name: str, record_type: str) -> dict[str, object]:
qtype = 1 if record_type == "A" else 16
query_id = random.randint(0, 65535)
flags = 0x0100
header = struct.pack(">HHHHHH", query_id, flags, 1, 0, 0, 0)
question = encode_dns_name(name) + struct.pack(">HH", qtype, 1)
message = header + question
start = time.monotonic()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(SOCKET_TIMEOUT)
sock.sendto(message, (DNS_SERVER, DNS_PORT))
payload, _ = sock.recvfrom(4096)
duration_ms = int((time.monotonic() - start) * 1000)
status, answers = parse_dns_answers(payload, qtype)
return {"status": status, "answers": answers, "latency_ms": duration_ms}
def run_dns_check(spec: dict[str, str]) -> dict[str, object]:
try:
result = dns_query(spec["name"], spec["record_type"])
outcome = "ok" if result["status"] == "NOERROR" and result["answers"] else "failed"
if spec["severity"] == "info" and result["status"] in {"NOERROR", "NXDOMAIN"}:
outcome = "ok"
detail = result["status"]
if result["answers"]:
detail = f"{result['status']} ({', '.join(result['answers'][:2])})"
return {
"kind": "dns",
"label": spec["label"],
"target": spec["name"],
"record_type": spec["record_type"],
"severity": spec["severity"],
"outcome": outcome,
"detail": detail,
"note": spec["note"],
"latency_ms": result["latency_ms"],
}
except Exception as exc:
return {
"kind": "dns",
"label": spec["label"],
"target": spec["name"],
"record_type": spec["record_type"],
"severity": spec["severity"],
"outcome": "failed",
"detail": str(exc),
"note": spec["note"],
"latency_ms": -1,
}
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks = []
remaining = size
while remaining > 0:
data = sock.recv(remaining)
if not data:
raise RuntimeError("unexpected EOF")
chunks.append(data)
remaining -= len(data)
return b"".join(chunks)
def socks_reply_message(code: int) -> str:
return {
0x00: "succeeded",
0x01: "general failure",
0x02: "connection not allowed",
0x03: "network unreachable",
0x04: "host unreachable",
0x05: "connection refused",
0x06: "TTL expired",
0x07: "command not supported",
0x08: "address type not supported",
}.get(code, f"reply {code}")
def run_proxy_check(users: list[dict[str, str]]) -> dict[str, object]:
if not users:
return {
"kind": "proxy",
"label": "SOCKS ingress",
"target": f"{PROXY_HOST}:{PROXY_PORT}",
"severity": "warning",
"outcome": "skipped",
"detail": "No proxy users found in secret",
"note": "Shows auth + CONNECT readiness",
"latency_ms": -1,
}
user = users[0]
host = "example.com".encode("idna")
start = time.monotonic()
try:
with socket.create_connection((PROXY_HOST, PROXY_PORT), timeout=SOCKET_TIMEOUT) as sock:
sock.settimeout(SOCKET_TIMEOUT)
sock.sendall(b"\x05\x01\x02")
greeting = recv_exact(sock, 2)
if greeting != b"\x05\x02":
raise RuntimeError(f"unexpected auth method {greeting!r}")
username = user["username"].encode("utf-8")
password = user["password"].encode("utf-8")
sock.sendall(bytes([0x01, len(username)]) + username + bytes([len(password)]) + password)
auth_reply = recv_exact(sock, 2)
if auth_reply[1] != 0x00:
raise RuntimeError("authentication failed")
request = b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", 80)
sock.sendall(request)
header = recv_exact(sock, 4)
reply_code = header[1]
addr_type = header[3]
if addr_type == 0x01:
recv_exact(sock, 4)
elif addr_type == 0x03:
domain_len = recv_exact(sock, 1)[0]
recv_exact(sock, domain_len)
elif addr_type == 0x04:
recv_exact(sock, 16)
recv_exact(sock, 2)
if reply_code != 0x00:
raise RuntimeError(socks_reply_message(reply_code))
except Exception as exc:
return {
"kind": "proxy",
"label": "SOCKS ingress",
"target": f"{PROXY_HOST}:{PROXY_PORT}",
"severity": "critical",
"outcome": "failed",
"detail": str(exc),
"note": "Shows auth + CONNECT readiness",
"latency_ms": -1,
}
return {
"kind": "proxy",
"label": "SOCKS ingress",
"target": f"{PROXY_HOST}:{PROXY_PORT}",
"severity": "critical",
"outcome": "ok",
"detail": f"Authenticated as {user['username']} and opened CONNECT tunnel",
"note": "Shows auth + CONNECT readiness",
"latency_ms": int((time.monotonic() - start) * 1000),
}
def service_severity(state: str, health: str, restart_count: int, error_excerpt: list[str]) -> str:
if state in {"exited", "dead"} or health == "unhealthy":
return "critical"
if state != "running" or health == "starting" or restart_count > 0 or error_excerpt:
return "degraded"
return "healthy"
def list_project_containers() -> list[dict[str, object]]:
filters = urllib.parse.quote(json.dumps({"label": [f"com.docker.compose.project={DOCKER_PROJECT}"]}, separators=(",", ":")))
raw_containers = docker_json(f"/containers/json?all=1&filters={filters}")
services = []
for item in raw_containers:
name = item.get("Names", [item["Id"]])[0].lstrip("/")
inspect = docker_json(f"/containers/{item['Id']}/json")
state = inspect["State"]["Status"]
health = inspect["State"].get("Health", {}).get("Status", "none")
restart_count = inspect.get("RestartCount", 0)
logs = service_log_text(item["Id"], LOG_TAIL_LINES)
error_excerpt = extract_error_excerpt(name, logs)
services.append(
{
"id": item["Id"],
"service": item.get("Labels", {}).get("com.docker.compose.service", name),
"container_name": name,
"state": state,
"status_text": item.get("Status", state),
"health": health,
"restart_count": restart_count,
"image": item.get("Image", ""),
"ports": item.get("Ports", []),
"severity": service_severity(state, health, restart_count, error_excerpt),
"error_excerpt": error_excerpt,
}
)
return sorted(services, key=lambda svc: svc["service"])
def summarize_status(services: list[dict[str, object]], checks: list[dict[str, object]]) -> dict[str, object]:
critical_services = sum(1 for svc in services if svc["severity"] == "critical")
degraded_services = sum(1 for svc in services if svc["severity"] == "degraded")
failed_critical_checks = sum(1 for check in checks if check["severity"] == "critical" and check["outcome"] != "ok")
failed_warning_checks = sum(1 for check in checks if check["severity"] == "warning" and check["outcome"] != "ok")
if critical_services or failed_critical_checks:
overall = "critical"
elif degraded_services or failed_warning_checks:
overall = "degraded"
else:
overall = "healthy"
return {
"overall": overall,
"service_counts": {
"total": len(services),
"healthy": sum(1 for svc in services if svc["severity"] == "healthy"),
"degraded": degraded_services,
"critical": critical_services,
},
"check_counts": {
"total": len(checks),
"ok": sum(1 for check in checks if check["outcome"] == "ok"),
"failed": sum(1 for check in checks if check["outcome"] == "failed"),
"skipped": sum(1 for check in checks if check["outcome"] == "skipped"),
},
}
def build_status_payload() -> dict[str, object]:
services = list_project_containers()
users = read_proxy_users()
checks = [run_dns_check(spec) for spec in DNS_CHECKS]
checks.append(run_proxy_check(users))
summary = summarize_status(services, checks)
return {
"generated_at": now_iso(),
"project": DOCKER_PROJECT,
"summary": summary,
"services": services,
"checks": checks,
"users": [{"username": entry["username"]} for entry in users],
}
def get_status_payload(force_refresh: bool = False) -> dict[str, object]:
now = time.monotonic()
with CACHE_LOCK:
if not force_refresh and STATUS_CACHE["payload"] is not None and now < STATUS_CACHE["expires_at"]:
return STATUS_CACHE["payload"]
payload = build_status_payload()
STATUS_CACHE["payload"] = payload
STATUS_CACHE["expires_at"] = now + STATUS_CACHE_SECONDS
return payload
def find_service(identifier: str) -> dict[str, object] | None:
payload = get_status_payload()
for service in payload["services"]:
if service["service"] == identifier or service["container_name"] == identifier:
return service
return None
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>darkproxy status</title>
<style>
:root {
color-scheme: dark;
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--text: #e5e7eb;
--muted: #94a3b8;
--healthy: #22c55e;
--degraded: #f59e0b;
--critical: #ef4444;
--border: #334155;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
h1, h2 {
margin: 0 0 12px;
}
.topbar, .cards, .panel-grid {
display: grid;
gap: 16px;
}
.topbar {
grid-template-columns: 1fr auto auto;
align-items: center;
margin-bottom: 16px;
}
.cards {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 16px;
}
.card, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.panel-grid {
grid-template-columns: 2fr 1fr;
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
padding: 10px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.healthy { color: var(--healthy); }
.degraded { color: var(--degraded); }
.critical { color: var(--critical); }
.badge.healthy { background: rgba(34, 197, 94, 0.15); }
.badge.degraded { background: rgba(245, 158, 11, 0.15); }
.badge.critical { background: rgba(239, 68, 68, 0.15); }
.badge.info { background: rgba(148, 163, 184, 0.15); color: var(--muted); }
.muted {
color: var(--muted);
font-size: 13px;
}
button, select {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
}
pre {
background: #020617;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
overflow: auto;
min-height: 220px;
white-space: pre-wrap;
word-break: break-word;
}
ul {
margin: 0;
padding-left: 18px;
}
.error-list {
margin: 0;
padding-left: 16px;
}
@media (max-width: 960px) {
.panel-grid, .topbar {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div>
<h1>darkproxy status</h1>
<div class="muted" id="updated-at">Loading…</div>
</div>
<button id="refresh-btn">Refresh now</button>
<a class="muted" href="/api/status" target="_blank" rel="noreferrer">JSON</a>
</div>
<div class="cards" id="summary-cards"></div>
<div class="panel-grid">
<div class="panel">
<h2>Services</h2>
<table>
<thead>
<tr>
<th>Service</th>
<th>State</th>
<th>Health</th>
<th>Restarts</th>
<th>Errors</th>
<th></th>
</tr>
</thead>
<tbody id="services-body"></tbody>
</table>
</div>
<div class="panel">
<h2>Users</h2>
<div class="muted">Configured proxy usernames only. Passwords are never shown.</div>
<ul id="users-list"></ul>
</div>
</div>
<div class="panel" style="margin-bottom: 16px;">
<h2>Checks</h2>
<table>
<thead>
<tr>
<th>Check</th>
<th>Target</th>
<th>Severity</th>
<th>Result</th>
<th>Latency</th>
<th>Detail</th>
</tr>
</thead>
<tbody id="checks-body"></tbody>
</table>
</div>
<div class="panel">
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px; flex-wrap:wrap;">
<h2 style="margin:0;">Logs</h2>
<select id="logs-service"></select>
<button id="load-logs-btn">Load logs</button>
</div>
<pre id="logs-output">Select a service to view recent logs.</pre>
</div>
</div>
<script>
let lastStatus = null;
function badgeClass(value) {
return ["healthy", "degraded", "critical"].includes(value) ? value : "info";
}
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;"
}[ch]));
}
function renderSummary(summary) {
const cards = [
["Overall", summary.overall],
["Services", `${summary.service_counts.healthy}/${summary.service_counts.total} healthy`],
["Degraded", `${summary.service_counts.degraded}`],
["Critical", `${summary.service_counts.critical}`],
["Checks OK", `${summary.check_counts.ok}/${summary.check_counts.total}`]
];
document.getElementById("summary-cards").innerHTML = cards.map(([label, value]) => `
<div class="card">
<div class="muted">${esc(label)}</div>
<div class="badge ${badgeClass(String(value).toLowerCase())}">${esc(value)}</div>
</div>
`).join("");
}
function renderServices(services) {
document.getElementById("services-body").innerHTML = services.map((service) => `
<tr>
<td>
<strong>${esc(service.service)}</strong><br>
<span class="muted">${esc(service.container_name)}</span>
</td>
<td><span class="badge ${badgeClass(service.severity)}">${esc(service.state)}</span><br><span class="muted">${esc(service.status_text)}</span></td>
<td>${esc(service.health)}</td>
<td>${esc(service.restart_count)}</td>
<td>
${service.error_excerpt.length ? `<ul class="error-list">${service.error_excerpt.map((line) => `<li>${esc(line)}</li>`).join("")}</ul>` : '<span class="muted">none</span>'}
</td>
<td><button data-service="${esc(service.service)}">Logs</button></td>
</tr>
`).join("");
const select = document.getElementById("logs-service");
const current = select.value;
select.innerHTML = services.map((service) => `<option value="${esc(service.service)}">${esc(service.service)}</option>`).join("");
if (services.some((service) => service.service === current)) {
select.value = current;
}
document.querySelectorAll("button[data-service]").forEach((button) => {
button.addEventListener("click", () => {
select.value = button.dataset.service;
loadLogs();
});
});
}
function renderChecks(checks) {
document.getElementById("checks-body").innerHTML = checks.map((check) => `
<tr>
<td><strong>${esc(check.label)}</strong><br><span class="muted">${esc(check.note)}</span></td>
<td>${esc(check.target)} ${check.record_type ? `<span class="muted">${esc(check.record_type)}</span>` : ""}</td>
<td><span class="badge ${badgeClass(check.severity === "warning" ? "degraded" : check.severity)}">${esc(check.severity)}</span></td>
<td><span class="badge ${badgeClass(check.outcome === "ok" ? "healthy" : (check.outcome === "skipped" ? "info" : "critical"))}">${esc(check.outcome)}</span></td>
<td>${check.latency_ms >= 0 ? `${esc(check.latency_ms)} ms` : '<span class="muted">n/a</span>'}</td>
<td>${esc(check.detail)}</td>
</tr>
`).join("");
}
function renderUsers(users) {
const list = document.getElementById("users-list");
if (!users.length) {
list.innerHTML = "<li class='muted'>No users found</li>";
return;
}
list.innerHTML = users.map((user) => `<li>${esc(user.username)}</li>`).join("");
}
async function loadStatus(force = false) {
const suffix = force ? "?refresh=1" : "";
const response = await fetch(`/api/status${suffix}`);
const payload = await response.json();
lastStatus = payload;
document.getElementById("updated-at").textContent = `Updated ${payload.generated_at}`;
renderSummary(payload.summary);
renderServices(payload.services);
renderChecks(payload.checks);
renderUsers(payload.users);
}
async function loadLogs() {
const service = document.getElementById("logs-service").value;
if (!service) {
return;
}
document.getElementById("logs-output").textContent = "Loading logs…";
const response = await fetch(`/api/logs?service=${encodeURIComponent(service)}`);
const payload = await response.json();
document.getElementById("logs-output").textContent = payload.logs || "(no log output)";
}
document.getElementById("refresh-btn").addEventListener("click", () => loadStatus(true));
document.getElementById("load-logs-btn").addEventListener("click", loadLogs);
loadStatus().catch((error) => {
document.getElementById("updated-at").textContent = `Failed to load status: ${error}`;
});
setInterval(() => loadStatus().catch(() => {}), 15000);
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
def send_json(self, payload: dict[str, object], status: int = 200):
body = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except BrokenPipeError:
return
def send_html(self, body: str, status: int = 200):
payload = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
try:
self.wfile.write(payload)
except BrokenPipeError:
return
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
try:
if parsed.path == "/":
self.send_html(HTML)
return
if parsed.path == "/api/status":
force_refresh = params.get("refresh", ["0"])[0] == "1"
self.send_json(get_status_payload(force_refresh=force_refresh))
return
if parsed.path == "/healthz":
self.send_json({"ok": True, "generated_at": now_iso()})
return
if parsed.path == "/api/users":
users = [{"username": entry["username"]} for entry in read_proxy_users()]
self.send_json({"users": users})
return
if parsed.path == "/api/logs":
service_name = params.get("service", [""])[0]
if not service_name:
self.send_json({"error": "missing service parameter"}, status=400)
return
service = find_service(service_name)
if service is None:
self.send_json({"error": f"unknown service {service_name}"}, status=404)
return
tail = int(params.get("tail", ["200"])[0])
self.send_json(
{
"service": service["service"],
"container_name": service["container_name"],
"logs": service_log_text(service["id"], tail),
}
)
return
self.send_json({"error": "not found"}, status=404)
except BrokenPipeError:
return
except Exception as exc:
self.send_json({"error": str(exc)}, status=500)
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", DASHBOARD_PORT), Handler)
server.serve_forever()