Add Linux server monitor dashboard

This commit is contained in:
yhlhoh committed 2026-10-21 12:05:29 +08:00
1 parent 0446648ed1
commit 315cb36a21
19 files changed
+5997 -1

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""Server Monitor - a dependency-free Linux dashboard."""
__version__ = "1.0.0"
+286
View File
@@ -0,0 +1,286 @@
"""Deterministic, realistic demo data for UI development and screenshots."""
from __future__ import annotations
import datetime as dt
import math
import random
import time
from typing import Any, Dict, List
from .metrics import build_alerts
class DemoSource:
"""A drop-in metrics source that simulates a busy Linux edge server."""
def __init__(self) -> None:
self.step = 0
self.rng = random.Random(20260609)
self._cores = 16
self._total_mem = 32 * 1024 ** 3
now = dt.datetime.now().astimezone()
offset = now.utcoffset()
self._tz_offset = int(offset.total_seconds()) if offset else 0
self._tz_name = now.tzname() or "LOCAL"
self._boot_base = time.time() - (12 * 86400 + 4 * 3600 + 27 * 60 + 18)
def snapshot(self) -> Dict[str, Any]:
return self.sample()
def sample(self) -> Dict[str, Any]:
self.step += 1
s = self.step
now = time.time()
uptime = 12 * 86400 + 4 * 3600 + 27 * 60 + 18 + s
cpu_base = 36 + 15 * math.sin(s / 15.0) + 5 * math.sin(s / 3.7) + self.rng.uniform(-2.2, 2.2)
cpu_base = max(8.0, min(88.0, cpu_base))
per_core = []
for index in range(self._cores):
value = cpu_base + 13 * math.sin(s / (7.0 + index * 0.6) + index * 0.9) + self.rng.uniform(-4, 4)
per_core.append(round(max(1.0, min(99.0, value)), 1))
cpu_percent = round(sum(per_core) / len(per_core), 1)
mem_percent = 61.5 + 4.5 * math.sin(s / 37.0) + 1.2 * math.sin(s / 9.0)
mem_percent = max(30.0, min(92.0, mem_percent))
total_mem = self._total_mem
used_mem = int(total_mem * mem_percent / 100.0)
available = total_mem - used_mem
free = int(available * 0.32)
buffers = int(total_mem * 0.018)
cached = int(total_mem * 0.235 + 1.5 * 1024 ** 3 * math.sin(s / 25.0))
apps = max(0, total_mem - free - buffers - cached)
swap_total = 8 * 1024 ** 3
swap_used = int(swap_total * (0.045 + 0.012 * math.sin(s / 19.0)))
swap_percent = round(swap_used / swap_total * 100, 1)
load1 = max(0.15, self._cores * cpu_percent / 100.0 * 0.92 + 0.25)
load5 = load1 * 0.94 + 0.08
load15 = load1 * 0.87 + 0.14
total_root = 512 * 1024 ** 3
total_data = 4 * 1024 ** 4
total_var = 128 * 1024 ** 3
total_boot = 1024 ** 3
disks = [
self._disk("/dev/nvme0n1p2", "/", "ext4", total_root, 46.4 + 0.3 * math.sin(s / 40)),
self._disk("/dev/nvme1n1", "/data", "xfs", total_data, 91.2 + 0.08 * math.sin(s / 55)),
self._disk("/dev/nvme0n1p3", "/var", "ext4", total_var, 67.8 + 0.4 * math.sin(s / 30)),
self._disk("/dev/nvme0n1p1", "/boot", "ext4", total_boot, 23.6),
]
read_mbps = 17.4 + 7.5 * math.sin(s / 6.0) + self.rng.uniform(-2, 2)
write_mbps = 6.1 + 3.2 * math.cos(s / 8.0) + self.rng.uniform(-1, 1)
read2 = 2.1 + 1.7 * math.sin(s / 5.0 + 1.4)
write2 = 1.2 + 0.9 * math.cos(s / 7.0)
disk_devices = [
{
"device": "nvme0n1",
"read_bps": int(read_mbps * 1024 ** 2),
"write_bps": int(write_mbps * 1024 ** 2),
"read_iops": round(780 + 240 * math.sin(s / 5), 1),
"write_iops": round(310 + 130 * math.cos(s / 6), 1),
"util_percent": round(min(99.0, 31 + 18 * math.sin(s / 9)), 1),
"read_await_ms": 0.31,
"write_await_ms": 0.44,
},
{
"device": "nvme1n1",
"read_bps": int(max(0, read2) * 1024 ** 2),
"write_bps": int(max(0, write2) * 1024 ** 2),
"read_iops": round(112 + 44 * math.sin(s / 4), 1),
"write_iops": round(86 + 25 * math.cos(s / 5), 1),
"util_percent": round(min(99.0, 12 + 8 * math.sin(s / 7 + 2)), 1),
"read_await_ms": 0.18,
"write_await_ms": 0.22,
},
]
rx_mbps = 12.6 + 5.2 * math.sin(s / 11.0) + 1.5 * math.sin(s / 2.9) + self.rng.uniform(-0.5, 0.5)
tx_mbps = 3.4 + 1.8 * math.cos(s / 9.0) + self.rng.uniform(-0.25, 0.25)
rx2_mbps = 1.35 + 0.55 * math.sin(s / 13.0 + 1.1)
tx2_mbps = 0.42 + 0.2 * math.cos(s / 10.0)
rx_bps = int(max(0, rx_mbps) * 1024 ** 2)
tx_bps = int(max(0, tx_mbps) * 1024 ** 2)
interfaces = [
self._iface("ens18", "10.20.4.17", rx_bps, tx_bps, 10_000, primary=True),
self._iface("ens19", "172.18.4.21", int(max(0, rx2_mbps) * 1024 ** 2), int(max(0, tx2_mbps) * 1024 ** 2), 1000),
self._iface("wg0", "10.88.0.2", int(0.82 * 1024 ** 2), int(0.31 * 1024 ** 2), None, virtual=True),
]
processes = self._processes(total_mem)
temp = round(53.2 + 2.6 * math.sin(s / 17.0), 1)
freq = int(3798 + 120 * math.sin(s / 5.0))
alerts = build_alerts(
cpu_percent=cpu_percent,
memory_percent=mem_percent,
swap_percent=swap_percent,
load1=load1,
cores=self._cores,
disks=disks,
temp_c=temp,
)
return {
"ts": now,
"version": "1.0.0",
"host": {
"hostname": "orion-edge-07",
"os": "Debian GNU/Linux 12 (bookworm)",
"kernel": "6.1.0-28-amd64",
"arch": "x86_64",
"cpu_model": "AMD Ryzen 7 5800X 8-Core Processor",
"cores": self._cores,
"physical_cores": 8,
"primary_ip": "10.20.4.17",
"ips": ["10.20.4.17", "10.88.0.2", "172.18.4.21"],
"boot_epoch": now - uptime,
"uptime_seconds": uptime,
"tz_offset": self._tz_offset,
"tz_name": self._tz_name,
"process_count": 214,
"thread_count": 438,
"version": "1.0.0",
"demo": True,
},
"cpu": {
"percent": cpu_percent,
"per_core": per_core,
"breakdown": {
"user": round(cpu_percent * 0.61, 1),
"nice": 0.3,
"system": round(cpu_percent * 0.27, 1),
"iowait": round(cpu_percent * 0.07, 1),
"irq": round(cpu_percent * 0.02, 1),
"softirq": round(cpu_percent * 0.02, 1),
"steal": 0.1,
},
"freq_mhz": freq,
"temp_c": temp,
"temp_label": "x86_pkg_temp",
"model": "AMD Ryzen 7 5800X 8-Core Processor",
"cores": self._cores,
"physical_cores": 8,
},
"memory": {
"total": total_mem,
"used": used_mem,
"free": free,
"available": available,
"buffers": buffers,
"cached": cached,
"apps": apps,
"percent": round(mem_percent, 1),
"composition": {
"apps": apps,
"cache": cached,
"buffers": buffers,
"free": free,
},
"swap_total": swap_total,
"swap_used": swap_used,
"swap_free": swap_total - swap_used,
"swap_percent": swap_percent,
"dirty": 48 * 1024 ** 2,
"writeback": 0,
},
"load": {
"load1": round(load1, 2),
"load5": round(load5, 2),
"load15": round(load15, 2),
"running": 3,
"threads": 438,
"percent": round(load1 / self._cores * 100, 1),
},
"disks": disks,
"disk_io": {
"devices": disk_devices,
"read_bps": sum(d["read_bps"] for d in disk_devices),
"write_bps": sum(d["write_bps"] for d in disk_devices),
},
"network": {
"interfaces": interfaces,
"interface_count": 3,
"total_rx_bps": rx_bps + int(max(0, rx2_mbps) * 1024 ** 2),
"total_tx_bps": tx_bps + int(max(0, tx2_mbps) * 1024 ** 2),
"primary_iface": "ens18",
"primary_ip": "10.20.4.17",
"all_ips": ["10.20.4.17", "10.88.0.2", "172.18.4.21"],
"errors": 0,
"drops": 0,
},
"processes": processes,
"alerts": alerts,
}
def _disk(self, device: str, mount: str, fstype: str, total: int, percent: float) -> Dict[str, Any]:
used = int(total * percent / 100)
free = total - used
return {
"device": device,
"mount": mount,
"fstype": fstype,
"total": total,
"used": used,
"free": free,
"percent": round(percent, 1),
"readonly": False,
"inodes_total": 100_000_000,
"inodes_free": 80_000_000,
}
def _iface(
self,
name: str,
ip: str,
rx_bps: int,
tx_bps: int,
speed: int | None,
primary: bool = False,
virtual: bool = False,
) -> Dict[str, Any]:
return {
"name": name,
"ip": ip,
"virtual": virtual,
"primary": primary,
"rx_bps": rx_bps,
"tx_bps": tx_bps,
"rx_total": 18_400_000_000_000 + rx_bps * 37,
"tx_total": 4_700_000_000_000 + tx_bps * 29,
"errors": 0,
"drops": 0,
"speed_mbps": speed,
"link": True,
}
def _processes(self, total_mem: int) -> List[Dict[str, Any]]:
rows = [
("node", 2203, 18.4, 3.1, "node /opt/edge-api/dist/server.js --cluster 4"),
("java", 4080, 14.1, 12.6, "java -Xms4g -Xmx6g -jar /opt/events/event-worker.jar"),
("postgres", 1442, 12.8, 8.4, "postgres: checkpointer"),
("python3", 3120, 7.6, 1.5, "python3 /opt/telemetry/collector.py --workers 8"),
("nginx", 2101, 4.2, 0.8, "nginx: worker process"),
("dockerd", 910, 2.1, 1.2, "/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock"),
("systemd-journald", 512, 1.8, 0.3, "/lib/systemd/systemd-journald"),
("redis-server", 1750, 1.2, 0.7, "redis-server 0.0.0.0:6379"),
("containerd", 1002, 0.8, 0.4, "/usr/bin/containerd"),
("kworker/3:2", 88, 0.4, 0.0, "[kworker/3:2-events]"),
("rsyslogd", 702, 0.2, 0.1, "/usr/sbin/rsyslogd -n -iNONE"),
("sshd", 2402, 0.1, 0.1, "sshd: /usr/sbin/sshd -D [listener]"),
]
out = []
for name, pid, cpu, mem, cmd in rows:
rss = int(total_mem * mem / 100)
out.append({
"pid": pid,
"name": name,
"cmd": cmd,
"cpu_percent": cpu,
"mem_percent": mem,
"rss_bytes": rss,
"threads": 1 if name.startswith("kworker") else 24,
})
return out
+1009
View File
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
"""Background sampler, history ring buffers and SSE fan-out."""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from collections import deque
from typing import Any, Dict, List, Optional
from .metrics import SystemCollector
logger = logging.getLogger("servermon.service")
HISTORY_WINDOWS = ("cpu", "mem", "load", "rx", "tx", "disk_r", "disk_w")
class MonitorService:
"""Samples the metrics source on a fixed cadence and serves immutable JSON.
All HTTP/SSE readers share the latest serialized snapshot, so displaying the
dashboard from several screens only costs one sampler.
"""
def __init__(
self,
source: Any,
interval: float = 1.0,
history_limit: int = 120,
display_points: int = 60,
) -> None:
self.source = source
self.interval = max(0.2, float(interval))
self.history_limit = max(10, int(history_limit))
self.display_points = max(10, min(display_points, self.history_limit))
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._current: Optional[Dict[str, Any]] = None
self._json = "{}"
self._history: Dict[str, deque] = {
key: deque(maxlen=self.history_limit) for key in HISTORY_WINDOWS
}
self._subscribers: set[queue.Queue] = set()
self._samples = 0
self._last_sample_ts: Optional[float] = None
self._last_error: Optional[str] = None
self._last_error_log = 0.0
self.started_at = time.time()
self._bootstrapped = False
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def bootstrap(self) -> None:
"""Populate the first snapshot (and demo history) before serving HTTP."""
if self._bootstrapped:
return
is_demo = self.source.__class__.__name__ == "DemoSource"
if is_demo:
# A full trend makes the panel useful immediately in demo mode.
for _ in range(self.display_points):
self._record(self.source.snapshot(), broadcast=False)
else:
self._record(self.source.snapshot(), broadcast=False)
self._bootstrapped = True
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
if not self._bootstrapped:
self.bootstrap()
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="sampler", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=3.0)
def _run(self) -> None:
next_run = time.monotonic()
while not self._stop.is_set():
next_run += self.interval
delay = next_run - time.monotonic()
if delay > 0:
self._stop.wait(delay)
else:
# The machine slept or sampling was slow; do not burst to catch up.
next_run = time.monotonic()
if self._stop.is_set():
break
try:
self.collect_once()
except Exception as exc: # pragma: no cover - defensive
self._last_error = f"{type(exc).__name__}: {exc}"
now = time.monotonic()
if now - self._last_error_log > 30.0:
logger.exception("metrics sample failed")
self._last_error_log = now
def collect_once(self) -> Dict[str, Any]:
snapshot = self.source.snapshot()
self._record(snapshot, broadcast=True)
return snapshot
# ------------------------------------------------------------------
# Snapshot storage
# ------------------------------------------------------------------
def _record(self, snapshot: Dict[str, Any], broadcast: bool) -> None:
cpu = snapshot.get("cpu") or {}
memory = snapshot.get("memory") or {}
load = snapshot.get("load") or {}
network = snapshot.get("network") or {}
disk_io = snapshot.get("disk_io") or {}
values = {
"cpu": float(cpu.get("percent") or 0.0),
"mem": float(memory.get("percent") or 0.0),
"load": float(load.get("load1") or 0.0),
"rx": float(network.get("total_rx_bps") or 0.0),
"tx": float(network.get("total_tx_bps") or 0.0),
"disk_r": float(disk_io.get("read_bps") or 0.0),
"disk_w": float(disk_io.get("write_bps") or 0.0),
}
with self._lock:
for key, value in values.items():
self._history[key].append(round(value, 1) if key != "load" else round(value, 2))
history = {
key: [round(v, 1) if key != "load" else round(v, 2) for v in list(values_deque)[-self.display_points:]]
for key, values_deque in self._history.items()
}
payload = dict(snapshot)
payload["history"] = history
payload["meta"] = {
"sample_interval": self.interval,
"history_points": self.display_points,
"history_seconds": int(self.display_points * self.interval),
"demo": bool((snapshot.get("host") or {}).get("demo")),
}
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
self._current = payload
self._json = encoded
self._samples += 1
self._last_sample_ts = time.time()
subscribers = list(self._subscribers)
if broadcast:
for subscriber in subscribers:
try:
if subscriber.full():
try:
subscriber.get_nowait()
except queue.Empty:
pass
subscriber.put_nowait(encoded)
except (queue.Full, ValueError):
pass
def snapshot(self) -> Optional[Dict[str, Any]]:
with self._lock:
return self._current
def snapshot_json(self) -> str:
with self._lock:
return self._json
def wait_ready(self, timeout: float = 5.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._current is not None:
return True
time.sleep(0.02)
return self._current is not None
# ------------------------------------------------------------------
# SSE subscribers
# ------------------------------------------------------------------
def subscribe(self) -> queue.Queue:
subscriber: queue.Queue = queue.Queue(maxsize=2)
with self._lock:
self._subscribers.add(subscriber)
return subscriber
def unsubscribe(self, subscriber: queue.Queue) -> None:
with self._lock:
self._subscribers.discard(subscriber)
def health(self) -> Dict[str, Any]:
with self._lock:
age = time.time() - self._last_sample_ts if self._last_sample_ts else None
return {
"ok": self._current is not None and (age is None or age < self.interval * 10),
"samples": self._samples,
"subscribers": len(self._subscribers),
"last_sample_age": round(age, 2) if age is not None else None,
"last_error": self._last_error,
"interval": self.interval,
"uptime_seconds": round(time.time() - self.started_at, 1),
}
+298
View File
@@ -0,0 +1,298 @@
"""Small, dependency-free HTTP server for the dashboard and its SSE stream."""
from __future__ import annotations
import base64
import hmac
import json
import logging
import queue
import socket
import threading
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import unquote, urlsplit
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import __version__
from .service import MonitorService
logger = logging.getLogger("servermon.web")
DEFAULT_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
STATIC_FILES: Dict[str, Tuple[str, str]] = {
"/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8"),
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/favicon.ico": ("favicon.svg", "image/svg+xml"),
"/robots.txt": ("robots.txt", "text/plain; charset=utf-8"),
}
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"X-Frame-Options": "SAMEORIGIN",
}
CSP = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"base-uri 'none'; "
"frame-ancestors 'self'"
)
class AuthConfig:
"""HTTP Basic authentication credentials, if configured."""
def __init__(self, username: str, password: str) -> None:
self.username = username
self.password = password
@classmethod
def parse(cls, raw: Optional[str]) -> Optional["AuthConfig"]:
if not raw:
return None
if ":" not in raw:
raise ValueError("认证信息格式应为 USER:PASSWORD")
username, password = raw.split(":", 1)
if not username or not password:
raise ValueError("认证用户名和密码不能为空")
return cls(username, password)
def check_header(self, header: str) -> bool:
if not header:
return False
scheme, _, encoded = header.partition(" ")
if scheme.lower() != "basic" or not encoded:
return False
try:
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return False
expected = f"{self.username}:{self.password}"
return hmac.compare_digest(decoded, expected)
class MonitorHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(
self,
server_address: Tuple[str, int],
handler_class: type,
service: MonitorService,
auth: Optional[AuthConfig],
web_dir: Path,
stop_event: threading.Event,
verbose: bool = False,
) -> None:
self.service = service
self.auth = auth
self.web_dir = web_dir
self.stop_event = stop_event
self.verbose = verbose
super().__init__(server_address, handler_class)
class MonitorHandler(BaseHTTPRequestHandler):
server_version = f"ServerMonitor/{__version__}"
sys_version = ""
protocol_version = "HTTP/1.0"
# ------------------------------------------------------------------
# Plumbing
# ------------------------------------------------------------------
def log_message(self, fmt: str, *args: Any) -> None:
if getattr(self.server, "verbose", False):
logger.info("%s - %s", self.address_string(), fmt % args)
def _base_headers(self, content_type: str, length: int, cache: str = "no-store") -> None:
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(length))
self.send_header("Cache-Control", cache)
for key, value in SECURITY_HEADERS.items():
self.send_header(key, value)
def _send_bytes(
self,
status: int,
body: bytes,
content_type: str,
extra: Optional[Dict[str, str]] = None,
cache: str = "no-store",
head_only: bool = False,
) -> None:
self.send_response(status)
self._base_headers(content_type, len(body), cache)
if extra:
for key, value in extra.items():
self.send_header(key, value)
self.end_headers()
if not head_only:
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
pass
def _send_json(self, status: int, payload: Any, head_only: bool = False) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send_bytes(status, body, "application/json; charset=utf-8", head_only=head_only)
def _authorized(self) -> bool:
auth = getattr(self.server, "auth", None)
if auth is None:
return True
if auth.check_header(self.headers.get("Authorization", "")):
return True
self._send_bytes(
401,
"Authentication required\n".encode("utf-8"),
"text/plain; charset=utf-8",
extra={"WWW-Authenticate": 'Basic realm="Server Monitor", charset="UTF-8"'},
)
return False
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
def do_GET(self) -> None: # noqa: N802
parsed = urlsplit(self.path)
path = unquote(parsed.path)
if path == "/healthz":
health = self.server.service.health()
self._send_json(200 if health["ok"] else 503, {
"ok": health["ok"],
"samples": health["samples"],
"interval": health["interval"],
"uptime_seconds": health["uptime_seconds"],
})
return
if not self._authorized():
return
if path == "/api/snapshot":
encoded = self.server.service.snapshot_json()
if encoded == "{}":
self._send_json(503, {"error": "sampling is warming up"})
return
body = encoded.encode("utf-8")
self._send_bytes(200, body, "application/json; charset=utf-8")
return
if path == "/api/stream":
self._stream()
return
static = STATIC_FILES.get(path)
if static:
filename, content_type = static
file_path = (self.server.web_dir / filename).resolve()
try:
body = file_path.read_bytes()
except OSError:
self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8")
return
extra = {"Content-Security-Policy": CSP} if content_type.startswith("text/html") else None
self._send_bytes(200, body, content_type, extra=extra, cache="no-cache")
return
self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8")
def do_HEAD(self) -> None: # noqa: N802
parsed = urlsplit(self.path)
path = unquote(parsed.path)
if path == "/healthz":
self._send_json(200, {"ok": True}, head_only=True)
return
if not self._authorized():
return
if path == "/api/snapshot":
body = self.server.service.snapshot_json().encode("utf-8")
self._send_bytes(200, body, "application/json; charset=utf-8", head_only=True)
return
static = STATIC_FILES.get(path)
if static:
filename, content_type = static
try:
body = (self.server.web_dir / filename).read_bytes()
except OSError:
self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8", head_only=True)
return
extra = {"Content-Security-Policy": CSP} if content_type.startswith("text/html") else None
self._send_bytes(200, body, content_type, extra=extra, cache="no-cache", head_only=True)
return
self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8", head_only=True)
def do_POST(self) -> None: # noqa: N802
self._send_bytes(405, b"Method not allowed\n", "text/plain; charset=utf-8", extra={"Allow": "GET, HEAD"})
# ------------------------------------------------------------------
# Server-sent events
# ------------------------------------------------------------------
def _stream(self) -> None:
service: MonitorService = self.server.service
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
for key, value in SECURITY_HEADERS.items():
self.send_header(key, value)
self.end_headers()
subscriber = service.subscribe()
try:
self.wfile.write(b"retry: 3000\n\n")
encoded = service.snapshot_json()
if encoded and encoded != "{}":
self.wfile.write(f"data: {encoded}\n\n".encode("utf-8"))
self.wfile.flush()
while not self.server.stop_event.is_set():
try:
payload = subscriber.get(timeout=15.0)
except queue.Empty:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
continue
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
pass
finally:
service.unsubscribe(subscriber)
class IPv6MonitorHTTPServer(MonitorHTTPServer):
address_family = socket.AF_INET6
def build_server(
bind: str,
port: int,
service: MonitorService,
auth: Optional[AuthConfig] = None,
web_dir: Optional[Path] = None,
stop_event: Optional[threading.Event] = None,
verbose: bool = False,
) -> MonitorHTTPServer:
if not 1 <= int(port) <= 65535:
raise ValueError("端口必须在 1-65535 之间")
web_dir = Path(web_dir) if web_dir else DEFAULT_WEB_DIR
if not (web_dir / "index.html").exists():
raise FileNotFoundError(f"找不到前端文件: {web_dir / 'index.html'}")
event = stop_event or threading.Event()
server_class = IPv6MonitorHTTPServer if ":" in bind else MonitorHTTPServer
return server_class((bind, int(port)), MonitorHandler, service, auth, web_dir, event, verbose)