Add Linux server monitor dashboard
This commit is contained in:
1 parent
0446648ed1
commit
315cb36a21
19 files changed
+5997
-1
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""Server Monitor - a dependency-free Linux dashboard."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user