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

+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)