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

+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.sh text eol=lf
*.service text eol=lf
*.py text eol=lf
*.png binary
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.log
.demo/
demo_snapshot.json
.DS_Store
+228 -1
View File
@@ -1,2 +1,229 @@
# server-monitor # Server Monitor
Linux 服务器监控展示牌。监听 `0.0.0.0`,通过 `/proc`、`/sys` 和 `statvfs` 采集系统指标,在 **1024×768** 优化的面板上自动滚动展示,并随屏幕尺寸等比缩放。
![面板预览](docs/preview.png)
## 特性
- **零第三方依赖**:只需要 Python 3.8+,不需要 pip 安装任何包。
- **完整指标**:CPU 使用率 / 每核心 / user-system-iowait-steal 拆分、内存与 Swap、LOAD 1/5/15、Uptime、文件系统、磁盘 I/O、网络速率与累计流量、Top 进程、温度、频率。
- **展示牌设计**:1024×768 基准,5 个页面自动轮播;顶部固定主机名、当前时间、时区、Uptime、IP;底部状态跑马灯。
- **实时数据**:默认每秒采样一次;SSE 推送,断线后自动回退到 HTTP 轮询。
- **无需前端构建**:原生 HTML/CSS/Canvas,无 CDN、无外网字体或图标,内网离线可用。
- **可选 Basic 认证**;systemd 服务使用 `DynamicUser` 降权运行,只读访问系统数据。
- **内置演示模式**:在没有 Linux `/proc` 的机器上也能预览完整面板。
## 面板
| 页面 | 内容 |
| --- | --- |
| 01 系统概览 | CPU / 内存 / LOAD / 文件系统 / 网络速率,节点信息 |
| 02 处理器与负载 | 大环图、16 核心网格、CPU 拆分、CPU Top 进程、负载趋势 |
| 03 内存 | 内存构成、Swap、60 秒趋势、内存 Top 进程 |
| 04 存储 | 文件系统用量汇总、块设备 I/O、I/O 60 秒趋势 |
| 05 网络 | 总吞吐、RX/TX 趋势、每网卡速率/累计/错误/链路速率 |
默认每页轮播 10 秒;任意页面都会保留主机名、服务器时间、时区和 Uptime。底部的 STATUS 跑马灯会滚动显示阈值告警与实时摘要。
## 快速开始
### Linux(实时模式)
```bash
# 需要 Python 3.8+
python3 monitor.py --bind 0.0.0.0 --port 8787
```
浏览器打开 `http://<服务器IP>:8787/`。
### 任意系统(演示模式)
```bash
python3 monitor.py --demo --bind 127.0.0.1 --port 8787
```
演示模式不读取本机 `/proc`,使用模拟的 16 核服务器数据,适合做 UI 预览、截图和无 Linux 环境下的开发。
### 一次性输出 JSON
```bash
python3 monitor.py --once --demo
python3 monitor.py --once # Linux 上采样两次后输出实时快照
```
## 安装为 systemd 服务
```bash
chmod +x install.sh
sudo ./install.sh
```
脚本会把程序安装到 `/opt/server-monitor`,安装 `server-monitor.service`,并立即启动。默认监听 `0.0.0.0:8787`。
```bash
systemctl status server-monitor --no-pager
journalctl -u server-monitor -f
```
如需启用 Basic 认证:
```bash
sudo vi /etc/server-monitor.env
# 写入:
# SERVER_MONITOR_AUTH=admin:换成一个强密码
sudo systemctl restart server-monitor
```
密码中不能包含换行;用户名和密码用第一个冒号分隔,密码本身可以包含冒号。修改端口推荐使用 systemd drop-in:
```bash
sudo systemctl edit server-monitor
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/python3 /opt/server-monitor/monitor.py --bind 0.0.0.0 --port 9000
```
然后执行 `sudo systemctl daemon-reload && sudo systemctl restart server-monitor`。
## 命令行参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `--bind` | `0.0.0.0` | 监听地址。只给本机看可用 `127.0.0.1` |
| `--port` | `8787` | 监听端口 |
| `--interval` | `1.0` | 采样间隔(秒,最小 0.2) |
| `--demo` | 关闭 | 使用演示数据,可在 Windows/macOS 运行 |
| `--once` | 关闭 | 输出一次 JSON 快照后退出 |
| `--auth USER:PASSWORD` | 无 | 启用 HTTP Basic 认证 |
| `--web-dir DIR` | 内置 `web/` | 前端静态文件目录 |
| `--verbose` | 关闭 | 输出 HTTP 访问日志 |
| `--version` | — | 显示版本 |
环境变量 `SERVER_MONITOR_AUTH` 等价于 `--auth`,systemd 安装方式使用该变量。
## URL 参数与快捷键
| 参数 | 示例 | 说明 |
| --- | --- | --- |
| `page` | `?page=3` | 初始显示第 1–5 页 |
| `rotate` | `?rotate=15` | 每页停留秒数(4–120,默认 10) |
| `paused` | `?paused=1` | 打开时暂停自动轮播 |
快捷键:
- `Space`:暂停 / 继续轮播
- `←` / `→`:上一页 / 下一页
- `1`–`5`:直接跳转页面
- `F`:全屏
适合做值守大屏或 kiosk。页面会按 `min(视口宽/1024, 视口高/768)` 等比缩放,4:3 屏幕刚好铺满,16:9 屏幕居中留边且不会拉伸。
## HTTP 接口
| 路径 | 说明 |
| --- | --- |
| `/` | 展示牌 |
| `/api/snapshot` | 当前完整 JSON 快照(含 60 点历史) |
| `/api/stream` | SSE 实时推送,每个采样点一条 `data:` |
| `/healthz` | 健康检查,返回 `{ok, samples, interval, uptime_seconds}` |
`/healthz` 不要求认证;其余路径在配置认证后需要 Basic 凭据。接口只读,不接受任何写入或控制指令。
## 指标来源
| 指标 | 来源 |
| --- | --- |
| CPU 总量 / 每核心 / 拆分 | `/proc/stat` |
| CPU 型号 / 物理核心 | `/proc/cpuinfo` |
| CPU 频率 | `cpufreq/scaling_cur_freq`,回退 `/proc/cpuinfo` |
| CPU 温度 | `/sys/class/thermal/thermal_zone*`,回退 `hwmon` |
| 内存 / Swap | `/proc/meminfo` |
| LOAD / 运行线程 | `/proc/loadavg` |
| Uptime / 启动时间 | `/proc/uptime` |
| 文件系统 | `/proc/mounts` + `statvfs(2)` |
| 磁盘 I/O | `/proc/diskstats` + `/sys/class/block`(过滤分区) |
| 网络速率 / 累计 | `/proc/net/dev` |
| 网卡 IP / 默认路由 | `ip -j addr`(可选)、`/proc/net/route`、UDP 路由探测 |
| 网卡链路速率 | `/sys/class/net/<iface>/speed` |
| Top 进程 | `/proc/<pid>/stat`、`/proc/<pid>/cmdline` |
| 主机信息 | `/etc/os-release`、`uname(2)`、`gethostname(2)` |
说明:
- 网络汇总默认排除 `lo`、veth、docker、br-、virbr、cni 等虚拟接口,避免容器主机重复计数;网卡明细仍会列出主要接口。
- 磁盘 I/O 已过滤分区设备,避免整盘和分区重复统计;容器内的 `overlay` 根文件系统会按实际挂载显示。
- 进程 CPU 百分比是按“全部逻辑核心”归一化的,可超过 100%(例如 4 核机器上单进程跑满 4 核约等于 400%)。
- 所有采样都在一个后台线程完成,多个浏览器同时打开也只产生一份系统采样。
## 安全与部署建议
- 默认监听所有网卡,但**无认证**。部署到公网前请启用 `SERVER_MONITOR_AUTH`,并用防火墙限制来源,或放在带 TLS 的反向代理后面。
- 程序以只读方式访问 `/proc`、`/sys` 和挂载点;systemd 单元使用 `DynamicUser=yes`、`ProtectSystem=strict`、空 capability 集。
- 如果服务器使用了 `hidepid=2`,普通用户可能看不到其他用户的进程,进程 Top 会相应减少;这是内核权限策略,不是面板故障。
- 如果把 `/home` 下的挂载点也纳入监控,请确认 systemd 的 `ProtectHome=read-only` 不会改变你的预期;可按需删除该行。
- Basic 认证只保护凭据传输前的内容,公网使用务必加 HTTPS。
## 开发与测试
```bash
python3 -m unittest discover -s tests -v
node --check web/app.js
```
测试使用临时伪造的 `/proc` 数据验证 CPU 差值、内存、网络速率、磁盘 I/O 和演示快照,不会读取或修改真实服务器数据。
目录结构:
```text
monitor.py CLI 入口
servermon/metrics.py /proc /sys 解析与快照
servermon/demo.py 演示数据源
servermon/service.py 后台采样、历史环形缓冲、SSE 广播
servermon/web.py HTTP / SSE / 静态文件 / Basic Auth
web/ 原生前端(HTML/CSS/Canvas)
deploy/server-monitor.service
install.sh
tests/test_metrics.py
```
## 卸载
```bash
sudo systemctl disable --now server-monitor
sudo rm -f /etc/systemd/system/server-monitor.service
sudo systemctl daemon-reload
sudo rm -rf /opt/server-monitor
sudo rm -f /etc/server-monitor.env
```
## 故障排查
**页面一直显示“连接中”或空白**
- 确认访问的是 `http://IP:8787/`,且防火墙放行端口。
- 查看 `journalctl -u server-monitor -f`。
- 非 Linux 上启动实时模式会直接报错,请加 `--demo` 或把程序部署到 Linux。
**中文显示成方框**
目标浏览器缺少中文字体。Debian/Ubuntu 可安装 `fonts-noto-cjk`,或在浏览器端使用包含中文字体的系统。
**磁盘列表为空**
检查挂载的文件系统类型是否在 `REAL_FS_TYPES` 中;容器内的特殊文件系统可能被有意过滤。可以先用 `findmnt` 确认挂载类型。
**进程 Top 为空**
检查 `/proc` 是否被 `hidepid` 限制,以及 systemd 的 `DynamicUser` 是否有权限读取目标进程的 `stat`。
**端口被占用**
```bash
ss -lntp | grep 8787
```
改用 `--port` 或 systemd drop-in 修改端口。
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=Server Monitor dashboard (Linux metrics on 1024x768)
Documentation=file:/opt/server-monitor/README.md
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/server-monitor
EnvironmentFile=-/etc/server-monitor.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 /opt/server-monitor/monitor.py --bind 0.0.0.0 --port 8787
Restart=on-failure
RestartSec=2s
TimeoutStopSec=8s
# The dashboard only reads /proc, /sys and mount statistics.
DynamicUser=yes
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0077
[Install]
WantedBy=multi-user.target
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Executable
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Install Server Monitor as a systemd service.
set -euo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "请使用 root 运行:sudo ./install.sh" >&2
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "未找到 systemctl,此脚本需要 systemd。" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "未找到 python3,请先安装 Python 3.8+。" >&2
exit 1
fi
if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 8) else 1)'; then
echo "Python 版本过低,需要 3.8 或更新版本。" >&2
exit 1
fi
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="/opt/server-monitor"
UNIT_PATH="/etc/systemd/system/server-monitor.service"
ENV_PATH="/etc/server-monitor.env"
install -d -m 0755 "${TARGET_DIR}"
install -m 0755 "${SOURCE_DIR}/monitor.py" "${TARGET_DIR}/monitor.py"
rm -rf "${TARGET_DIR}/servermon" "${TARGET_DIR}/web"
cp -a "${SOURCE_DIR}/servermon" "${TARGET_DIR}/servermon"
cp -a "${SOURCE_DIR}/web" "${TARGET_DIR}/web"
install -m 0644 "${SOURCE_DIR}/README.md" "${TARGET_DIR}/README.md"
if [[ ! -f "${ENV_PATH}" ]]; then
cat >"${ENV_PATH}" <<'EOF'
# Server Monitor 环境变量(可选)
# 取消下一行注释并改成自己的凭据即可启用 HTTP Basic 认证:
# SERVER_MONITOR_AUTH=admin:change-me
EOF
chmod 0600 "${ENV_PATH}"
fi
install -m 0644 "${SOURCE_DIR}/deploy/server-monitor.service" "${UNIT_PATH}"
systemctl daemon-reload
systemctl enable --now server-monitor.service
systemctl restart server-monitor.service
echo
echo "Server Monitor 已安装并启动。"
echo " 访问: http://<服务器IP>:8787/"
echo " 状态: systemctl status server-monitor --no-pager"
echo " 日志: journalctl -u server-monitor -f"
echo " 认证: 编辑 ${ENV_PATH} 后 systemctl restart server-monitor"
echo
systemctl --no-pager --lines=0 status server-monitor.service || true
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Server Monitor entry point.
Usage:
python3 monitor.py --bind 0.0.0.0 --port 8787
python3 monitor.py --demo
python3 monitor.py --once --demo
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import signal
import socket
import sys
import threading
import time
from pathlib import Path
from typing import Optional
from servermon import __version__
from servermon.demo import DemoSource
from servermon.metrics import SystemCollector
from servermon.service import MonitorService
from servermon.web import AuthConfig, build_server
logger = logging.getLogger("servermon")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="server-monitor",
description="Linux 服务器监控展示牌:CPU / 内存 / 负载 / 磁盘 / 网络,1024x768 自适应面板。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--bind", default="0.0.0.0", help="监听地址")
parser.add_argument("--port", type=int, default=8787, help="监听端口")
parser.add_argument("--interval", type=float, default=1.0, help="采样间隔(秒,最小 0.2)")
parser.add_argument("--demo", action="store_true", help="使用内置演示数据(可在非 Linux 上运行)")
parser.add_argument("--once", action="store_true", help="打印一次 JSON 快照后退出")
parser.add_argument(
"--auth",
default=None,
metavar="USER:PASSWORD",
help="可选 HTTP Basic 认证;也可用环境变量 SERVER_MONITOR_AUTH",
)
parser.add_argument("--web-dir", default=None, help="前端静态文件目录")
parser.add_argument("--verbose", action="store_true", help="输出 HTTP 访问日志")
parser.add_argument("--version", action="version", version=f"server-monitor {__version__}")
return parser
def _local_ips() -> list:
ips = []
try:
host = socket.gethostname()
for info in socket.getaddrinfo(host, None, socket.AF_INET):
ip = info[4][0]
if ip not in ips and not ip.startswith("127."):
ips.append(ip)
except OSError:
pass
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.2)
sock.connect(("1.1.1.1", 80))
ip = sock.getsockname()[0]
sock.close()
if ip not in ips:
ips.append(ip)
except OSError:
pass
return ips
def _print_banner(bind: str, port: int, demo: bool, auth: Optional[AuthConfig]) -> None:
print()
print(" ╭──────────────────────────────────────────────────────────╮")
print(" │ SERVER MONITOR · 1024x768 · realtime metrics │")
print(" ╰──────────────────────────────────────────────────────────╯")
print(f" 模式 : {'DEMO 演示数据' if demo else 'LIVE 系统指标'}")
print(f" 监听 : {bind}:{port}")
if bind in ("0.0.0.0", "::", ""):
print(f" 本机访问 : http://127.0.0.1:{port}/")
for ip in _local_ips():
print(f" 局域网 : http://{ip}:{port}/")
elif ":" in bind:
print(f" 访问 : http://[{bind}]:{port}/")
else:
print(f" 访问 : http://{bind}:{port}/")
print(f" 认证 : {'HTTP Basic 已启用' if auth else '未启用(建议仅暴露在可信网络)'}")
print(" 快捷键 : SPACE 暂停轮播 · ←/→ 切换 · F 全屏")
print()
def _run_once(args: argparse.Namespace) -> int:
source = DemoSource() if args.demo else SystemCollector()
service = MonitorService(source, interval=args.interval)
service.bootstrap()
if not args.demo:
time.sleep(max(0.3, min(2.0, args.interval)))
service.collect_once()
snapshot = service.snapshot()
if snapshot is None:
print("无法生成快照", file=sys.stderr)
return 1
json.dump(snapshot, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
def main(argv: Optional[list] = None) -> int:
args = _build_parser().parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
if args.interval < 0.2:
logger.error("--interval 不能小于 0.2 秒")
return 2
if not 1 <= args.port <= 65535:
logger.error("--port 必须在 1-65535 之间")
return 2
try:
auth_raw = args.auth if args.auth is not None else os.environ.get("SERVER_MONITOR_AUTH")
auth = AuthConfig.parse(auth_raw)
except ValueError as exc:
logger.error("认证配置错误: %s", exc)
return 2
if args.once:
return _run_once(args)
if not args.demo and not sys.platform.startswith("linux"):
logger.error("实时模式仅支持 Linux。开发预览请加 --demo。")
return 2
source = DemoSource() if args.demo else SystemCollector()
service = MonitorService(source, interval=args.interval)
service.bootstrap()
stop_event = threading.Event()
web_dir = Path(args.web_dir) if args.web_dir else None
try:
server = build_server(
args.bind,
args.port,
service,
auth=auth,
web_dir=web_dir,
stop_event=stop_event,
verbose=args.verbose,
)
except (OSError, ValueError, FileNotFoundError) as exc:
logger.error("无法启动 HTTP 服务: %s", exc)
return 1
def shutdown(signum: int, _frame: object) -> None:
logger.info("收到信号 %s,正在停止…", signum)
stop_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, shutdown)
except (ValueError, OSError):
pass
server_thread = threading.Thread(target=server.serve_forever, name="http", daemon=True)
server_thread.start()
service.start()
_print_banner(args.bind, args.port, args.demo, auth)
if auth is None and args.bind not in ("127.0.0.1", "::1", "localhost"):
logger.warning("当前未启用认证且监听非回环地址,请用防火墙限制访问范围。")
try:
while not stop_event.wait(0.5):
pass
except KeyboardInterrupt:
stop_event.set()
finally:
service.stop()
server.shutdown()
server.server_close()
logger.info("已停止")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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)
View File
Whitespace-only changes.
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from servermon.demo import DemoSource
from servermon.metrics import (
SystemCollector,
build_alerts,
parse_cpu_stat,
parse_diskstats,
parse_loadavg,
parse_meminfo,
parse_mounts,
parse_net_dev,
)
from servermon.service import MonitorService
from servermon.web import AuthConfig
CPU_STAT_1 = """cpu 100 0 100 800 0 0 0 0 0 0
cpu0 50 0 50 400 0 0 0 0 0 0
cpu1 50 0 50 400 0 0 0 0 0 0
intr 0
"""
CPU_STAT_2 = """cpu 150 0 150 900 0 0 0 0 0 0
cpu0 75 0 75 450 0 0 0 0 0 0
cpu1 75 0 75 450 0 0 0 0 0 0
intr 0
"""
MEMINFO = """MemTotal: 1048576 kB
MemFree: 262144 kB
MemAvailable: 524288 kB
Buffers: 65536 kB
Cached: 131072 kB
SReclaimable: 16384 kB
Shmem: 2048 kB
SwapTotal: 262144 kB
SwapFree: 196608 kB
Dirty: 128 kB
"""
NET_DEV_1 = """Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 100000 100 0 0 0 0 0 0 100000 100 0 0 0 0 0 0
eth0: 1000 10 1 2 0 0 0 0 500 5 0 0 0 0 0 0
"""
NET_DEV_2 = """Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 100000 100 0 0 0 0 0 0 100000 100 0 0 0 0 0 0
eth0: 2000 20 1 2 0 0 0 0 1000 10 0 0 0 0 0 0
"""
DISKSTATS_1 = """ 8 0 sda 10 0 1000 5 10 0 500 3 0 8 0 0 0 0 0
8 1 sda1 10 0 1000 5 10 0 500 3 0 8 0 0 0 0 0
"""
DISKSTATS_2 = """ 8 0 sda 20 0 3000 10 20 0 1500 6 0 16 2 0 0 0 0
8 1 sda1 20 0 3000 10 20 0 1500 6 0 16 2 0 0 0 0
"""
class ParserTests(unittest.TestCase):
def test_cpu_stat(self) -> None:
aggregate, cores = parse_cpu_stat(CPU_STAT_1)
self.assertIsNotNone(aggregate)
self.assertEqual(aggregate["total"], 1000)
self.assertEqual(aggregate["idle_all"], 800)
self.assertEqual(len(cores), 2)
self.assertEqual(cores[0]["index"], 0)
def test_meminfo(self) -> None:
info = parse_meminfo(MEMINFO)
self.assertEqual(info["total"], 1048576 * 1024)
self.assertEqual(info["available"], 524288 * 1024)
self.assertEqual(info["swap_total"], 262144 * 1024)
def test_loadavg(self) -> None:
load = parse_loadavg("1.50 1.00 0.50 2/100 1234\n")
self.assertEqual(load["load1"], 1.5)
self.assertEqual(load["running"], 2)
self.assertEqual(load["threads"], 100)
def test_net_dev(self) -> None:
net = parse_net_dev(NET_DEV_1)
self.assertEqual(net["eth0"]["rx_bytes"], 1000)
self.assertEqual(net["eth0"]["tx_bytes"], 500)
self.assertEqual(net["eth0"]["rx_errors"], 1)
self.assertEqual(net["eth0"]["rx_drops"], 2)
self.assertIn("lo", net)
def test_diskstats(self) -> None:
stats = parse_diskstats(DISKSTATS_1)
self.assertEqual(stats["sda"]["sectors_read"], 1000)
self.assertEqual(stats["sda"]["sectors_written"], 500)
self.assertEqual(stats["sda1"]["reads"], 10)
def test_mounts_unescape(self) -> None:
mounts = parse_mounts("/dev/sda1 /mnt/my\\040disk ext4 rw,relatime 0 0\n")
self.assertEqual(mounts[0]["mount"], "/mnt/my disk")
self.assertEqual(mounts[0]["fstype"], "ext4")
def test_alerts(self) -> None:
alerts = build_alerts(
cpu_percent=96,
memory_percent=62,
swap_percent=10,
load1=2,
cores=8,
disks=[{"mount": "/data", "percent": 91}],
temp_c=55,
)
self.assertTrue(any(a["severity"] == "crit" for a in alerts))
self.assertTrue(any("/data" in a["message"] for a in alerts))
class AuthTests(unittest.TestCase):
def test_basic_auth(self) -> None:
import base64
auth = AuthConfig.parse("admin:s3cret")
assert auth is not None
token = base64.b64encode(b"admin:s3cret").decode("ascii")
self.assertTrue(auth.check_header("Basic " + token))
self.assertFalse(auth.check_header("Basic " + base64.b64encode(b"admin:wrong").decode("ascii")))
self.assertFalse(auth.check_header("Bearer " + token))
self.assertFalse(auth.check_header(""))
def test_invalid_auth_config(self) -> None:
with self.assertRaises(ValueError):
AuthConfig.parse("missing-colon")
with self.assertRaises(ValueError):
AuthConfig.parse(":password")
self.assertIsNone(AuthConfig.parse(""))
class CollectorTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
self.proc = root / "proc"
self.sys = root / "sys"
self.proc.mkdir()
self.sys.mkdir()
(root / "etc").mkdir()
(root / "etc" / "os-release").write_text(
'PRETTY_NAME="Test Linux 1.0"\nNAME="Test"\nVERSION="1.0"\n', encoding="utf-8"
)
self._write_fixture(CPU_STAT_1, NET_DEV_1, DISKSTATS_1)
(self.proc / "meminfo").write_text(MEMINFO, encoding="utf-8")
(self.proc / "loadavg").write_text("1.50 1.00 0.50 2/100 1234\n", encoding="utf-8")
(self.proc / "uptime").write_text("3600.00 1200.00\n", encoding="utf-8")
(self.proc / "cpuinfo").write_text(
"processor\t: 0\nmodel name\t: Test CPU\nphysical id\t: 0\ncore id\t: 0\n\n"
"processor\t: 1\nmodel name\t: Test CPU\nphysical id\t: 0\ncore id\t: 1\n\n",
encoding="utf-8",
)
(self.proc / "mounts").write_text("", encoding="utf-8")
(self.proc / "route").write_text(
"Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n"
"eth0\t00000000\t0100000A\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
encoding="utf-8",
)
self.clock_values = iter([100.0, 101.0])
self.collector = SystemCollector(
proc_root=str(self.proc),
sys_root=str(self.sys),
clock=lambda: next(self.clock_values),
wall_clock=lambda: 1000.0,
)
def _write_fixture(self, cpu: str, net: str, disk: str) -> None:
(self.proc / "stat").write_text(cpu, encoding="utf-8")
(self.proc / "net").mkdir(exist_ok=True)
(self.proc / "net" / "dev").write_text(net, encoding="utf-8")
(self.proc / "diskstats").write_text(disk, encoding="utf-8")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_deltas(self) -> None:
first = self.collector.snapshot()
self.assertEqual(first["cpu"]["percent"], 20.0)
self.assertEqual(first["memory"]["percent"], 50.0)
self.assertEqual(first["load"]["load1"], 1.5)
self.assertEqual(first["host"]["uptime_seconds"], 3600.0)
self._write_fixture(CPU_STAT_2, NET_DEV_2, DISKSTATS_2)
second = self.collector.snapshot()
self.assertEqual(second["cpu"]["percent"], 50.0)
self.assertEqual(second["cpu"]["per_core"], [50.0, 50.0])
self.assertEqual(second["network"]["total_rx_bps"], 1000)
self.assertEqual(second["network"]["total_tx_bps"], 500)
self.assertEqual(second["disk_io"]["read_bps"], 1024000)
self.assertEqual(second["disk_io"]["write_bps"], 512000)
self.assertEqual(len(second["disk_io"]["devices"]), 1)
self.assertEqual(second["disk_io"]["devices"][0]["device"], "sda")
self.assertNotIn("sda1", [d["device"] for d in second["disk_io"]["devices"]])
def test_demo_service_history(self) -> None:
service = MonitorService(DemoSource(), interval=1.0)
service.bootstrap()
payload = service.snapshot()
self.assertIsNotNone(payload)
assert payload is not None
self.assertEqual(len(payload["history"]["cpu"]), 60)
self.assertEqual(len(payload["history"]["disk_r"]), 60)
self.assertEqual(payload["meta"]["demo"], True)
json.dumps(payload, ensure_ascii=False)
if __name__ == "__main__":
unittest.main()
+1273
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#59f2d8"/>
<stop offset="1" stop-color="#4d9dff"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#07111c"/>
<circle cx="32" cy="32" r="25" fill="none" stroke="#2b6d77" stroke-opacity=".45"/>
<path d="M6 33h9l5-13 8 26 7-18 4 9h19" fill="none" stroke="url(#g)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="2.4" fill="#65f5d8"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

+485
View File
@@ -0,0 +1,485 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#05080e">
<title>Server Monitor</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<svg class="svg-defs" aria-hidden="true">
<defs>
<linearGradient id="brand-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#59f2d8"/>
<stop offset="100%" stop-color="#4d9dff"/>
</linearGradient>
</defs>
</svg>
<div class="viewport">
<main class="stage" id="stage">
<div class="stage-grid" aria-hidden="true"></div>
<div class="stage-glow glow-a" aria-hidden="true"></div>
<div class="stage-glow glow-b" aria-hidden="true"></div>
<header class="topbar panel">
<div class="identity">
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48">
<circle cx="24" cy="24" r="21" fill="none" stroke="rgba(96, 226, 212, .18)" stroke-width="1"/>
<circle cx="24" cy="24" r="15" fill="none" stroke="rgba(96, 226, 212, .10)" stroke-width="1" stroke-dasharray="2 5"/>
<path d="M5 25h7.5l3.7-9.5 6.3 19 5.4-13.4 3.5 7.2h11.6" fill="none" stroke="url(#brand-gradient)" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="24" cy="24" r="2.6" fill="#65f5d8"/>
</svg>
</div>
<div class="identity-copy">
<div class="hostline">
<h1 id="hostname">---</h1>
<span class="live-pill" id="live-pill" data-state="connecting"><i></i><span id="live-text">连接中</span></span>
<span class="demo-pill" id="demo-pill" hidden>DEMO</span>
</div>
<div class="host-meta">
<span id="os-name">--</span>
<i class="sep"></i>
<span id="kernel">--</span>
<i class="sep"></i>
<span id="arch">--</span>
<i class="sep"></i>
<span class="host-ip"><span class="ip-dot"></span><b id="primary-ip">--</b></span>
</div>
</div>
</div>
<div class="clock-block">
<div class="clock" id="clock">--:--:--</div>
<div class="clock-meta"><span id="date">----</span><i class="sep"></i><span id="timezone">UTC</span></div>
</div>
<div class="uptime-block">
<div class="eyebrow">UPTIME</div>
<div class="uptime" id="uptime">--</div>
<div class="boot" id="boot-time">BOOT --</div>
</div>
</header>
<nav class="nav" aria-label="面板导航">
<div class="nav-current">
<span class="page-index" id="page-index">01</span>
<div class="nav-titles">
<strong id="page-title">系统概览</strong>
<span id="page-sub">SYSTEM OVERVIEW</span>
</div>
</div>
<div class="dots" id="dots" role="tablist" aria-label="切换面板"></div>
<div class="nav-state">
<i class="state-led" id="state-led"></i>
<span id="rotation-state">自动轮播</span>
<span class="key-hint">SPACE</span>
</div>
<div class="progress" aria-hidden="true"><i id="page-progress"></i></div>
</nav>
<main class="pages" id="pages">
<!-- 01 · OVERVIEW -->
<article class="page page-overview" data-page="0" aria-label="系统概览">
<section class="card card-cpu">
<header class="card-head">
<div class="card-title"><span class="cn">处理器</span><span class="en">CPU</span></div>
<span class="card-badge" id="cpu-badge">-- THREADS</span>
</header>
<div class="cpu-main">
<div class="ring ring-cpu" id="cpu-ring" style="--pct:0">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-value" cx="60" cy="60" r="52" pathLength="100"></circle>
</svg>
<div class="ring-center"><b id="cpu-percent">0<span>%</span></b><small>UTIL</small></div>
</div>
<div class="cpu-facts">
<p class="cpu-model" id="cpu-model">--</p>
<div class="fact-row"><span>频率</span><b id="cpu-freq">--</b></div>
<div class="fact-row"><span>温度</span><b id="cpu-temp">--</b></div>
<div class="fact-row"><span>核心</span><b id="cpu-cores">--</b></div>
</div>
</div>
<div class="spark-wrap">
<div class="spark-head"><span>60S 使用率</span><span class="spark-peak" id="cpu-peak">PEAK --</span></div>
<canvas id="cpu-spark" class="spark" aria-label="CPU 使用率趋势"></canvas>
</div>
</section>
<section class="card card-mem">
<header class="card-head">
<div class="card-title"><span class="cn">内存</span><span class="en">MEMORY</span></div>
<span class="card-badge" id="mem-badge">--</span>
</header>
<div class="mem-main">
<div class="ring ring-mem" id="mem-ring" style="--pct:0">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-value" cx="60" cy="60" r="52" pathLength="100"></circle>
</svg>
<div class="ring-center"><b id="mem-percent">0<span>%</span></b><small>USED</small></div>
</div>
<div class="mem-facts">
<div class="mem-total"><b id="mem-used">--</b><span>/ <em id="mem-total">--</em></span></div>
<div class="kv-line"><span>可用 AVAIL</span><b id="mem-available">--</b></div>
<div class="kv-line"><span>缓存 CACHE</span><b id="mem-cache">--</b></div>
</div>
</div>
<div class="composition" id="mem-composition">
<i class="seg seg-apps" id="mem-seg-apps"></i>
<i class="seg seg-cache" id="mem-seg-cache"></i>
<i class="seg seg-buffers" id="mem-seg-buffers"></i>
<i class="seg seg-free" id="mem-seg-free"></i>
</div>
<div class="composition-legend">
<span><i class="legend-dot apps"></i>进程</span>
<span><i class="legend-dot cache"></i>缓存</span>
<span><i class="legend-dot free"></i>空闲</span>
</div>
<div class="swap-line">
<span class="swap-label">SWAP</span>
<div class="mini-bar"><i id="swap-bar"></i></div>
<b id="swap-text">--</b>
</div>
</section>
<section class="card card-sys">
<header class="card-head">
<div class="card-title"><span class="cn">节点</span><span class="en">NODE</span></div>
<span class="card-badge">HOST</span>
</header>
<div class="sys-list">
<div class="sys-row"><span>系统</span><b id="sys-os">--</b></div>
<div class="sys-row"><span>内核</span><b id="sys-kernel">--</b></div>
<div class="sys-row"><span>架构</span><b id="sys-arch">--</b></div>
<div class="sys-row"><span>地址</span><b id="sys-ip">--</b></div>
<div class="sys-row"><span>启动</span><b id="sys-boot">--</b></div>
<div class="sys-row"><span>进程</span><b id="sys-procs">--</b></div>
</div>
</section>
<section class="card card-load">
<header class="card-head">
<div class="card-title"><span class="cn">负载</span><span class="en">LOAD</span></div>
<span class="card-badge" id="load-badge">-- CORES</span>
</header>
<div class="load-stats">
<div class="load-stat">
<span>1 MIN</span><b id="load1">--</b>
<div class="mini-bar"><i id="load1-bar"></i></div>
</div>
<div class="load-stat">
<span>5 MIN</span><b id="load5">--</b>
<div class="mini-bar"><i id="load5-bar"></i></div>
</div>
<div class="load-stat">
<span>15 MIN</span><b id="load15">--</b>
<div class="mini-bar"><i id="load15-bar"></i></div>
</div>
</div>
<div class="load-foot">
<span>运行中 <b id="load-running">--</b></span>
<span>线程 <b id="load-threads">--</b></span>
<span>规范值 <b id="load-pct">--</b></span>
</div>
<div class="spark-wrap spark-wrap-load">
<div class="spark-head"><span>LOAD 1M · 60S</span><span class="spark-peak" id="load-peak">PEAK --</span></div>
<canvas id="load-spark" class="spark" aria-label="负载趋势"></canvas>
</div>
</section>
<section class="card card-disk">
<header class="card-head">
<div class="card-title"><span class="cn">文件系统</span><span class="en">DISK</span></div>
<span class="card-badge" id="disk-count">-- FS</span>
</header>
<div class="disk-list" id="disk-overview"></div>
</section>
<section class="card card-net">
<header class="card-head">
<div class="card-title"><span class="cn">网络</span><span class="en">NETWORK</span></div>
<span class="card-badge live-badge"><i></i>LIVE</span>
</header>
<div class="net-rates">
<div class="net-rate rx">
<span>↓ 下行 RX</span>
<b id="net-rx">--</b>
</div>
<div class="net-rate tx">
<span>↑ 上行 TX</span>
<b id="net-tx">--</b>
</div>
</div>
<div class="spark-wrap spark-wrap-net">
<canvas id="net-spark" class="spark" aria-label="网络吞吐趋势"></canvas>
</div>
<div class="net-foot">
<span><b id="net-ifaces">--</b> 个接口</span>
<span id="net-errors">0 错误</span>
</div>
</section>
</article>
<!-- 02 · CPU & LOAD -->
<article class="page page-cpu" data-page="1" aria-label="处理器与负载">
<section class="card cpu-hero">
<header class="card-head">
<div class="card-title"><span class="cn">处理器详情</span><span class="en">CPU DETAIL</span></div>
<span class="card-badge" id="cpu-badge-2">--</span>
</header>
<div class="hero-ring">
<div class="ring ring-hero ring-cpu" id="cpu-ring-2" style="--pct:0">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-value" cx="60" cy="60" r="52" pathLength="100"></circle>
</svg>
<div class="ring-center"><b id="cpu-percent-2">0<span>%</span></b><small>CPU UTIL</small></div>
</div>
</div>
<p class="hero-model" id="cpu-model-2">--</p>
<div class="hero-facts">
<div><span>频率</span><b id="cpu-freq-2">--</b></div>
<div><span>温度</span><b id="cpu-temp-2">--</b></div>
<div><span>逻辑核心</span><b id="cpu-cores-2">--</b></div>
<div><span>采样</span><b id="sample-interval">--</b></div>
</div>
<div class="breakdown" id="cpu-breakdown">
<div class="breakdown-row"><span>USER</span><div class="mini-bar"><i id="bd-user"></i></div><b id="bd-user-val">--</b></div>
<div class="breakdown-row"><span>SYSTEM</span><div class="mini-bar"><i id="bd-system"></i></div><b id="bd-system-val">--</b></div>
<div class="breakdown-row"><span>IOWAIT</span><div class="mini-bar"><i id="bd-iowait"></i></div><b id="bd-iowait-val">--</b></div>
<div class="breakdown-row"><span>STEAL</span><div class="mini-bar"><i id="bd-steal"></i></div><b id="bd-steal-val">--</b></div>
</div>
<div class="spark-wrap spark-wrap-hero">
<div class="spark-head"><span>60S 使用率</span><span class="spark-peak" id="cpu-peak-2">PEAK --</span></div>
<canvas id="cpu-spark-2" class="spark" aria-label="CPU 使用率趋势"></canvas>
</div>
</section>
<section class="card core-card">
<header class="card-head">
<div class="card-title"><span class="cn">每核心</span><span class="en">PER CORE</span></div>
<span class="card-badge" id="core-count">-- CORES</span>
</header>
<div class="core-grid" id="core-grid"></div>
</section>
<section class="card proc-card">
<header class="card-head">
<div class="card-title"><span class="cn">进程 TOP</span><span class="en">BY CPU</span></div>
<span class="card-badge" id="proc-count">-- PROCS</span>
</header>
<table class="data-table proc-table">
<thead><tr><th class="col-pid">PID</th><th>进程 / PROCESS</th><th class="num">CPU</th><th class="num">MEM</th><th class="num">RSS</th></tr></thead>
<tbody id="proc-cpu-body"></tbody>
</table>
</section>
</article>
<!-- 03 · MEMORY -->
<article class="page page-memory" data-page="2" aria-label="内存">
<section class="card mem-hero">
<header class="card-head">
<div class="card-title"><span class="cn">物理内存</span><span class="en">MEMORY</span></div>
<span class="card-badge" id="mem-badge-2">--</span>
</header>
<div class="hero-ring">
<div class="ring ring-hero ring-mem" id="mem-ring-2" style="--pct:0">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-value" cx="60" cy="60" r="52" pathLength="100"></circle>
</svg>
<div class="ring-center"><b id="mem-percent-2">0<span>%</span></b><small>USED</small></div>
</div>
</div>
<div class="mem-total-line"><b id="mem-used-2">--</b><span>/ <em id="mem-total-2">--</em> 已用</span></div>
<div class="composition composition-large" id="mem-composition-2">
<i class="seg seg-apps" id="mem-seg-apps-2"></i>
<i class="seg seg-cache" id="mem-seg-cache-2"></i>
<i class="seg seg-buffers" id="mem-seg-buffers-2"></i>
<i class="seg seg-free" id="mem-seg-free-2"></i>
</div>
<div class="mem-stats">
<div><span>可用 AVAILABLE</span><b id="mem-available-2">--</b></div>
<div><span>缓存 CACHED</span><b id="mem-cache-2">--</b></div>
<div><span>缓冲 BUFFERS</span><b id="mem-buffers-2">--</b></div>
<div><span>SWAP</span><b id="swap-text-2">--</b></div>
</div>
<div class="mem-breakdown">
<div class="mem-bd-head"><span>内存构成</span><span>COMPOSITION</span></div>
<div class="mem-bd-row"><span><i class="mem-key apps"></i>进程</span><div class="mini-bar"><i id="mem-bd-apps"></i></div><b id="mem-bd-apps-val">--</b></div>
<div class="mem-bd-row"><span><i class="mem-key cache"></i>缓存</span><div class="mini-bar"><i id="mem-bd-cache"></i></div><b id="mem-bd-cache-val">--</b></div>
<div class="mem-bd-row"><span><i class="mem-key buffers"></i>缓冲</span><div class="mini-bar"><i id="mem-bd-buffers"></i></div><b id="mem-bd-buffers-val">--</b></div>
<div class="mem-bd-row"><span><i class="mem-key free"></i>空闲</span><div class="mini-bar"><i id="mem-bd-free"></i></div><b id="mem-bd-free-val">--</b></div>
</div>
<div class="spark-wrap spark-wrap-mem">
<div class="spark-head"><span>60S 使用率</span><span class="spark-peak" id="mem-peak-2">PEAK --</span></div>
<canvas id="mem-spark-2" class="spark" aria-label="内存使用率趋势"></canvas>
</div>
</section>
<section class="card mem-trend">
<header class="card-head">
<div class="card-title"><span class="cn">内存趋势</span><span class="en">60 SECONDS</span></div>
<span class="card-badge" id="mem-trend-badge">--</span>
</header>
<div class="trend-chart"><canvas id="mem-spark-large" class="spark" aria-label="内存使用率趋势"></canvas></div>
<div class="trend-stats">
<div><span>当前 CURRENT</span><b id="mem-trend-current">--</b></div>
<div><span>峰值 PEAK</span><b id="mem-trend-peak">--</b></div>
<div><span>均值 AVG</span><b id="mem-trend-avg">--</b></div>
<div><span>交换 SWAP</span><b id="mem-trend-swap">--</b></div>
</div>
</section>
<section class="card proc-card mem-proc-card">
<header class="card-head">
<div class="card-title"><span class="cn">进程 TOP</span><span class="en">BY MEMORY</span></div>
<span class="card-badge" id="mem-proc-count">-- PROCS</span>
</header>
<table class="data-table proc-table">
<thead><tr><th class="col-pid">PID</th><th>进程 / PROCESS</th><th class="num">MEM</th><th class="num">RSS</th><th class="num">CPU</th></tr></thead>
<tbody id="proc-mem-body"></tbody>
</table>
</section>
</article>
<!-- 04 · STORAGE -->
<article class="page page-storage" data-page="3" aria-label="存储">
<section class="card fs-card">
<header class="card-head">
<div class="card-title"><span class="cn">文件系统</span><span class="en">FILESYSTEMS</span></div>
<span class="card-badge" id="fs-badge">-- FS</span>
</header>
<table class="data-table fs-table">
<thead>
<tr>
<th>挂载点 / MOUNT</th>
<th>设备 / DEVICE</th>
<th>类型</th>
<th class="num">容量</th>
<th class="num">已用</th>
<th class="num">可用</th>
<th class="bar-col">使用率</th>
<th class="num">%</th>
</tr>
</thead>
<tbody id="fs-body"></tbody>
</table>
<div class="fs-summary">
<div class="fs-sum-item"><span>总容量</span><b id="fs-total">--</b></div>
<div class="fs-sum-item"><span>已用</span><b id="fs-used">--</b></div>
<div class="fs-sum-item"><span>可用</span><b id="fs-free">--</b></div>
<div class="fs-sum-bar"><div class="mini-bar"><i id="fs-total-bar"></i></div><b id="fs-total-pct">--</b></div>
<div class="fs-sum-more" id="fs-more">--</div>
</div>
</section>
<section class="card io-card">
<header class="card-head">
<div class="card-title"><span class="cn">磁盘 I/O</span><span class="en">DEVICE THROUGHPUT</span></div>
<span class="card-badge" id="io-badge">R -- · W --</span>
</header>
<div class="io-grid">
<div class="io-main">
<table class="data-table io-table">
<thead>
<tr><th>设备 / DEVICE</th><th class="num">读 READ</th><th class="num">写 WRITE</th><th class="num">IOPS R/W</th><th class="num">延迟 AWAIT</th><th class="bar-col">忙碌 BUSY</th><th class="num">%</th></tr>
</thead>
<tbody id="io-body"></tbody>
</table>
<div class="io-footer">
<span>设备 <b id="io-device-count">--</b></span>
<span>最忙 <b id="io-busiest">--</b></span>
<span>累计读 <b id="io-total-read">--</b></span>
<span>累计写 <b id="io-total-write">--</b></span>
</div>
</div>
<div class="io-side">
<div class="io-side-head"><span>I/O 趋势 · 60S</span><b id="io-side-rate">--</b></div>
<canvas id="io-spark" class="spark" aria-label="磁盘 I/O 趋势"></canvas>
<div class="io-legend"><span class="read">READ</span><span class="write">WRITE</span></div>
</div>
</div>
</section>
</article>
<!-- 05 · NETWORK -->
<article class="page page-network" data-page="4" aria-label="网络">
<section class="card net-hero-card">
<header class="card-head">
<div class="card-title"><span class="cn">网络吞吐</span><span class="en">THROUGHPUT</span></div>
<span class="card-badge" id="net-badge">-- IFACES</span>
</header>
<div class="net-hero-body">
<div class="net-big rx">
<span class="net-direction">↓ 下行 RX</span>
<b id="net-rx-big">--</b>
<small id="net-rx-total">累计 --</small>
</div>
<div class="net-big-chart">
<canvas id="net-spark-big" class="spark" aria-label="网络吞吐趋势"></canvas>
<div class="chart-legend"><span class="rx">RX</span><span class="tx">TX</span><span class="window">60S WINDOW</span></div>
</div>
<div class="net-big tx">
<span class="net-direction">↑ 上行 TX</span>
<b id="net-tx-big">--</b>
<small id="net-tx-total">累计 --</small>
</div>
</div>
<div class="net-summary">
<span>接口 <b id="net-sum-ifaces">--</b></span>
<span>错误 <b id="net-sum-errors">--</b></span>
<span>丢包 <b id="net-sum-drops">--</b></span>
<span>主接口 <b id="net-sum-primary">--</b></span>
<span class="net-sum-live"><i></i>LIVE</span>
</div>
</section>
<section class="card iface-card">
<header class="card-head">
<div class="card-title"><span class="cn">网卡</span><span class="en">INTERFACES</span></div>
<span class="card-badge" id="iface-badge">--</span>
</header>
<table class="data-table iface-table">
<thead>
<tr><th>接口 / IFACE</th><th>IP</th><th class="num">下行 RX</th><th class="num">上行 TX</th><th class="num">累计 RX/TX</th><th class="num">错误/丢包</th><th class="num">链路</th></tr>
</thead>
<tbody id="iface-body"></tbody>
</table>
</section>
</article>
</main>
<footer class="ticker">
<div class="ticker-label">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1 8h3l2-5 3.4 10L12 8h3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>STATUS</span>
</div>
<div class="ticker-window">
<div class="ticker-track" id="ticker-track">
<span class="ticker-copy" id="ticker-a">正在连接服务器 · SERVER MONITOR INITIALIZING</span>
<span class="ticker-copy" id="ticker-b" aria-hidden="true">正在连接服务器 · SERVER MONITOR INITIALIZING</span>
</div>
</div>
<div class="ticker-side"><span id="ticker-sample">SAMPLE --</span><i class="sep"></i><span id="ticker-update">--</span></div>
</footer>
<div class="footline">
<span class="foot-left"><i class="foot-led" id="foot-led"></i> SERVER MONITOR <b id="version">v--</b><span class="sep"></span><span id="foot-mode">--</span></span>
<span class="foot-hint">← → 切换 <i class="sep"></i> SPACE 暂停 <i class="sep"></i> F 全屏</span>
<span class="foot-right" id="foot-sample">等待采样…</span>
</div>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+1677
View File
File diff suppressed because it is too large. Load diff