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,5 @@
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
*.py text eol=lf
|
||||
*.png binary
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
.demo/
|
||||
demo_snapshot.json
|
||||
.DS_Store
|
||||
@@ -1,2 +1,229 @@
|
||||
# server-monitor
|
||||
# Server Monitor
|
||||
|
||||
Linux 服务器监控展示牌。监听 `0.0.0.0`,通过 `/proc`、`/sys` 和 `statvfs` 采集系统指标,在 **1024×768** 优化的面板上自动滚动展示,并随屏幕尺寸等比缩放。
|
||||
|
||||

|
||||
|
||||
## 特性
|
||||
|
||||
- **零第三方依赖**:只需要 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 修改端口。
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 568 KiB |
Executable
+58
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+1677
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user