diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1bd4f5f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.sh text eol=lf +*.service text eol=lf +*.py text eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be96641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.log +.demo/ +demo_snapshot.json +.DS_Store diff --git a/README.md b/README.md index af3ea2d..80e05c7 100644 --- a/README.md +++ b/README.md @@ -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//speed` | +| Top 进程 | `/proc//stat`、`/proc//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 修改端口。 diff --git a/deploy/server-monitor.service b/deploy/server-monitor.service new file mode 100644 index 0000000..569b68f --- /dev/null +++ b/deploy/server-monitor.service @@ -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 diff --git a/docs/preview.png b/docs/preview.png new file mode 100644 index 0000000..17035e1 Binary files /dev/null and b/docs/preview.png differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..79d41ee --- /dev/null +++ b/install.sh @@ -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 diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..e5abb1b --- /dev/null +++ b/monitor.py @@ -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()) diff --git a/servermon/__init__.py b/servermon/__init__.py new file mode 100644 index 0000000..181b78c --- /dev/null +++ b/servermon/__init__.py @@ -0,0 +1,3 @@ +"""Server Monitor - a dependency-free Linux dashboard.""" + +__version__ = "1.0.0" diff --git a/servermon/demo.py b/servermon/demo.py new file mode 100644 index 0000000..63785f7 --- /dev/null +++ b/servermon/demo.py @@ -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 diff --git a/servermon/metrics.py b/servermon/metrics.py new file mode 100644 index 0000000..72f1276 --- /dev/null +++ b/servermon/metrics.py @@ -0,0 +1,1009 @@ +"""Linux system metric collection using /proc, /sys and statvfs. + +The module intentionally depends only on the Python standard library so the +dashboard can run on a bare server. Every parser is a pure function and can be +tested with synthetic /proc data (see tests/test_metrics.py). +""" + +from __future__ import annotations + +import datetime as dt +import glob +import json +import os +import platform +import re +import shutil +import socket +import subprocess +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +try: # package import + from . import __version__ +except ImportError: # pragma: no cover - direct module execution + __version__ = "1.0.0" + + +# Filesystems worth showing on a storage dashboard. Pseudo filesystems are +# deliberately excluded so the panel does not fill with tmpfs/proc entries. +REAL_FS_TYPES = { + "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "jfs", "reiserfs", + "bcachefs", "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "overlay", "aufs", + "nfs", "nfs4", "cifs", "smb3", "ceph", "glusterfs", "lustre", "9p", +} + +VIRTUAL_IFACE_PREFIXES = ( + "lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "kube", "cali", + "tunl", "vxlan", "dummy", "ifb", "lxc", "podman", +) + +# Linux /proc/stat fields, in order after the "cpu" label. +_CPU_FIELDS = ("user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal") + +_MEMINFO_KEYS = { + "MemTotal": "total", + "MemFree": "free", + "MemAvailable": "available", + "Buffers": "buffers", + "Cached": "cached", + "SReclaimable": "sreclaimable", + "Shmem": "shmem", + "SwapTotal": "swap_total", + "SwapFree": "swap_free", + "Dirty": "dirty", + "Writeback": "writeback", + "Slab": "slab", +} + +_STAT_RE = re.compile(r"^(\d+) \((.+)\) ([A-Za-z]) (.*)$") + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace") + except (OSError, ValueError): + return "" + + +def _as_int(value: str, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _pct(used: float, total: float) -> float: + if total <= 0: + return 0.0 + return max(0.0, min(100.0, used / total * 100.0)) + + +def _round(value: Optional[float], digits: int = 1) -> Optional[float]: + if value is None: + return None + return round(float(value), digits) + + +def parse_cpu_stat(text: str) -> Tuple[Optional[Dict[str, int]], List[Dict[str, int]]]: + """Parse /proc/stat CPU times into aggregate and per-core dictionaries.""" + + aggregate: Optional[Dict[str, int]] = None + cores: List[Dict[str, int]] = [] + for line in text.splitlines(): + if not line.startswith("cpu"): + continue + parts = line.split() + if len(parts) < 5: + continue + label = parts[0] + try: + values = [int(v) for v in parts[1:9]] + except ValueError: + continue + values += [0] * (8 - len(values)) + item = dict(zip(_CPU_FIELDS, values)) + item["total"] = sum(values) + item["idle_all"] = item["idle"] + item["iowait"] + if label == "cpu": + aggregate = item + elif label[3:].isdigit(): + item["index"] = int(label[3:]) + cores.append(item) + cores.sort(key=lambda c: c["index"]) + return aggregate, cores + + +def parse_cpuinfo(text: str) -> Dict[str, Any]: + """Extract model name, logical cores and physical core count from cpuinfo.""" + + model = "" + processors = 0 + physical = set() + physical_id: Optional[str] = None + core_id: Optional[str] = None + for line in text.splitlines() + [""]: + if not line.strip(): + if physical_id is not None and core_id is not None: + physical.add((physical_id, core_id)) + physical_id = core_id = None + continue + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip().lower() + value = value.strip() + if key == "processor": + processors += 1 + elif key in ("model name", "hardware", "processor model") and not model: + model = value + elif key == "physical id": + physical_id = value + elif key == "core id": + core_id = value + return { + "model": model, + "logical_cores": processors, + "physical_cores": len(physical) if physical else processors, + } + + +def parse_meminfo(text: str) -> Dict[str, int]: + """Parse /proc/meminfo into a canonical byte-valued dictionary.""" + + data: Dict[str, int] = {} + for line in text.splitlines(): + if ":" not in line: + continue + key, raw = line.split(":", 1) + key = key.strip() + target = _MEMINFO_KEYS.get(key) + if not target: + continue + match = re.search(r"(\d+)", raw) + if not match: + continue + # All tracked fields are reported in kB by the kernel. + data[target] = int(match.group(1)) * 1024 + return data + + +def parse_loadavg(text: str) -> Dict[str, Any]: + parts = text.split() + if len(parts) < 5: + return {"load1": 0.0, "load5": 0.0, "load15": 0.0, "running": 0, "threads": 0} + running = threads = 0 + if "/" in parts[3]: + left, right = parts[3].split("/", 1) + running, threads = _as_int(left), _as_int(right) + return { + "load1": float(parts[0]), + "load5": float(parts[1]), + "load15": float(parts[2]), + "running": running, + "threads": threads, + } + + +def parse_uptime(text: str) -> float: + parts = text.split() + if not parts: + return 0.0 + try: + return max(0.0, float(parts[0])) + except ValueError: + return 0.0 + + +def parse_net_dev(text: str) -> Dict[str, Dict[str, int]]: + """Parse /proc/net/dev, returning cumulative counters by interface.""" + + result: Dict[str, Dict[str, int]] = {} + for line in text.splitlines(): + if ":" not in line: + continue + name, raw = line.split(":", 1) + name = name.strip() + fields = raw.split() + if not name or len(fields) < 16: + continue + try: + values = [int(v) for v in fields[:16]] + except ValueError: + continue + result[name] = { + "rx_bytes": values[0], + "rx_packets": values[1], + "rx_errors": values[2], + "rx_drops": values[3], + "tx_bytes": values[8], + "tx_packets": values[9], + "tx_errors": values[10], + "tx_drops": values[11], + } + return result + + +def parse_diskstats(text: str) -> Dict[str, Dict[str, int]]: + """Parse /proc/diskstats into cumulative counters by block device.""" + + result: Dict[str, Dict[str, int]] = {} + for line in text.splitlines(): + parts = line.split() + if len(parts) < 14: + continue + name = parts[2] + try: + values = [int(v) for v in parts[3:14]] + except ValueError: + continue + result[name] = { + "reads": values[0], + "reads_merged": values[1], + "sectors_read": values[2], + "read_ms": values[3], + "writes": values[4], + "writes_merged": values[5], + "sectors_written": values[6], + "write_ms": values[7], + "io_in_progress": values[8], + "io_ms": values[9], + "weighted_io_ms": values[10], + } + return result + + +def _unescape_mount(value: str) -> str: + def replace(match: re.Match[str]) -> str: + return chr(int(match.group(1), 8)) + + return re.sub(r"\\([0-7]{3})", replace, value) + + +def parse_mounts(text: str) -> List[Dict[str, str]]: + mounts = [] + for line in text.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + mounts.append({ + "device": _unescape_mount(parts[0]), + "mount": _unescape_mount(parts[1]), + "fstype": parts[2], + "options": parts[3], + }) + return mounts + + +def build_alerts( + cpu_percent: float, + memory_percent: float, + swap_percent: float, + load1: float, + cores: int, + disks: Sequence[Dict[str, Any]], + temp_c: Optional[float], +) -> List[Dict[str, str]]: + """Build compact, user-facing alert messages for the status ticker.""" + + alerts: List[Dict[str, str]] = [] + + def add(severity: str, message: str) -> None: + alerts.append({"severity": severity, "message": message}) + + if cpu_percent >= 95: + add("crit", f"CPU 使用率 {cpu_percent:.0f}%") + elif cpu_percent >= 85: + add("warn", f"CPU 使用率 {cpu_percent:.0f}%") + + if memory_percent >= 95: + add("crit", f"内存使用率 {memory_percent:.0f}%") + elif memory_percent >= 85: + add("warn", f"内存使用率 {memory_percent:.0f}%") + + if swap_percent >= 80: + add("crit", f"Swap 已用 {swap_percent:.0f}%") + elif swap_percent >= 50: + add("warn", f"Swap 已用 {swap_percent:.0f}%") + + if cores > 0 and load1 >= cores * 3: + add("crit", f"负载 {load1:.2f} / {cores} 核") + elif cores > 0 and load1 >= cores * 1.5: + add("warn", f"负载 {load1:.2f} / {cores} 核") + + for disk in sorted(disks, key=lambda d: d.get("percent", 0), reverse=True): + pct = float(disk.get("percent", 0)) + if pct >= 95: + add("crit", f"磁盘 {disk.get('mount', '?')} 已用 {pct:.0f}%") + elif pct >= 85: + add("warn", f"磁盘 {disk.get('mount', '?')} 已用 {pct:.0f}%") + if len([a for a in alerts if a["message"].startswith("磁盘")]) >= 2: + break + + if temp_c is not None: + if temp_c >= 90: + add("crit", f"CPU 温度 {temp_c:.0f}°C") + elif temp_c >= 80: + add("warn", f"CPU 温度 {temp_c:.0f}°C") + + order = {"crit": 0, "warn": 1, "info": 2} + alerts.sort(key=lambda a: order.get(a["severity"], 3)) + return alerts + + +class SystemCollector: + """Collect one coherent snapshot from a Linux-like /proc and /sys tree.""" + + def __init__( + self, + proc_root: str = "/proc", + sys_root: str = "/sys", + clock: Callable[[], float] = time.monotonic, + wall_clock: Callable[[], float] = time.time, + ) -> None: + self.proc_root = Path(proc_root) + self.sys_root = Path(sys_root) + self.clock = clock + self.wall_clock = wall_clock + self._ncpu = os.cpu_count() or 1 + self._page_size = self._get_page_size() + self._static = self._read_static() + + self._prev_cpu: Optional[Dict[str, int]] = None + self._prev_cpu_cores: List[Dict[str, int]] = [] + self._cpu_total_now = 0 + self._prev_net: Dict[str, Dict[str, int]] = {} + self._prev_net_ts: Optional[float] = None + self._prev_disk: Dict[str, Dict[str, int]] = {} + self._prev_disk_ts: Optional[float] = None + self._proc_ticks: Dict[int, int] = {} + self._prev_proc_total = 0 + self._proc_cache: List[Dict[str, Any]] = [] + self._proc_cache_ts = 0.0 + self._cpu_meta: Dict[str, Any] = {"freq_mhz": None, "temp_c": None, "temp_label": None} + self._cpu_meta_ts = 0.0 + self._disk_cache: List[Dict[str, Any]] = [] + self._disk_cache_ts = 0.0 + self._addr_cache: Dict[str, Any] = {"interfaces": {}, "primary": None, "all": []} + self._addr_cache_ts = 0.0 + + def _get_page_size(self) -> int: + try: + return int(os.sysconf("SC_PAGE_SIZE")) + except (AttributeError, ValueError, OSError): + return 4096 + + def _read_static(self) -> Dict[str, Any]: + cpuinfo = parse_cpuinfo(_read_text(self.proc_root / "cpuinfo")) + osr: Dict[str, str] = {} + for line in _read_text(Path("/etc/os-release")).splitlines(): + if "=" in line: + key, value = line.split("=", 1) + osr[key] = value.strip().strip('"') + if not osr: + for candidate in (self.proc_root.parent / "etc" / "os-release",): + for line in _read_text(candidate).splitlines(): + if "=" in line: + key, value = line.split("=", 1) + osr[key] = value.strip().strip('"') + os_name = osr.get("PRETTY_NAME") or osr.get("NAME") or platform.platform() + if osr.get("VERSION") and osr.get("VERSION") not in os_name: + os_name = f"{os_name} {osr['VERSION']}" + + cores = len(self._per_core_names()) or self._ncpu + physical = cpuinfo.get("physical_cores") or cores + now = dt.datetime.now().astimezone() + offset = now.utcoffset() + tz_offset = int(offset.total_seconds()) if offset else -time.timezone + tz_name = now.tzname() or time.tzname[0] or "LOCAL" + + return { + "hostname": socket.gethostname() or "unknown", + "os": os_name, + "kernel": platform.release(), + "arch": platform.machine() or "unknown", + "cpu_model": cpuinfo.get("model") or platform.processor() or "Unknown CPU", + "cores": cores, + "physical_cores": physical, + "tz_offset": tz_offset, + "tz_name": tz_name, + "version": __version__, + } + + def _per_core_names(self) -> List[str]: + return sorted(p.name for p in self.sys_root.glob("devices/system/cpu/cpu[0-9]*") if p.is_dir()) + + # ------------------------------------------------------------------ + # Host / address helpers + # ------------------------------------------------------------------ + def _default_interface(self) -> Optional[str]: + text = _read_text(self.proc_root / "net" / "route") + best: Optional[Tuple[int, str]] = None + for line in text.splitlines()[1:]: + fields = line.split() + if len(fields) < 8: + continue + iface, dest, _gw, flags, _ref, _use, metric, _mask = fields[:8] + try: + flag_value = int(flags, 16) + except ValueError: + continue + if dest == "00000000" and flag_value & 0x2: + try: + value = int(metric) + except ValueError: + value = 0 + if best is None or value < best[0]: + best = (value, iface) + return best[1] if best else None + + def _read_addresses(self) -> Dict[str, Any]: + mapping: Dict[str, List[str]] = {} + ip_bin = shutil.which("ip") + if ip_bin: + try: + proc = subprocess.run( + [ip_bin, "-j", "addr", "show"], + capture_output=True, + text=True, + timeout=1.5, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + for item in json.loads(proc.stdout): + ifname = item.get("ifname") + if not ifname: + continue + addresses = [] + for addr in item.get("addr_info", []): + if addr.get("family") == "inet" and addr.get("local"): + addresses.append(addr["local"]) + if addresses: + mapping[ifname] = addresses + except (OSError, ValueError, subprocess.SubprocessError): + mapping = {} + + primary: Optional[str] = None + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(0.2) + sock.connect(("1.1.1.1", 80)) + primary = sock.getsockname()[0] + except OSError: + primary = None + + default_iface = self._default_interface() + if primary and default_iface and primary not in mapping.get(default_iface, []): + mapping.setdefault(default_iface, []).insert(0, primary) + + all_ips = sorted({ip for values in mapping.values() for ip in values}) + if not primary and all_ips: + primary = all_ips[0] + return {"interfaces": mapping, "primary": primary, "all": all_ips} + + def _addresses(self, now: float) -> Dict[str, Any]: + if now - self._addr_cache_ts >= 30.0 or not self._addr_cache_ts: + self._addr_cache = self._read_addresses() + self._addr_cache_ts = now + return self._addr_cache + + # ------------------------------------------------------------------ + # CPU / memory / load + # ------------------------------------------------------------------ + def _read_cpu(self, now: float) -> Dict[str, Any]: + aggregate, cores = parse_cpu_stat(_read_text(self.proc_root / "stat")) + if aggregate is None: + return { + "percent": 0.0, + "per_core": [], + "breakdown": {}, + "freq_mhz": None, + "temp_c": None, + "temp_label": None, + "model": self._static["cpu_model"], + "cores": self._static["cores"], + "physical_cores": self._static["physical_cores"], + } + + self._cpu_total_now = aggregate["total"] + first = self._prev_cpu is None + if first: + used = aggregate["total"] - aggregate["idle_all"] + total = aggregate["total"] + percent = _pct(used, total) + breakdown = { + key: _pct(aggregate.get(key, 0), total) + for key in ("user", "nice", "system", "iowait", "irq", "softirq", "steal") + } + per_core = [ + _pct(core["total"] - core["idle_all"], core["total"]) for core in cores + ] + else: + prev = self._prev_cpu + total_delta = max(0, aggregate["total"] - prev["total"]) + idle_delta = max(0, aggregate["idle_all"] - prev["idle_all"]) + percent = _pct(total_delta - idle_delta, total_delta) + breakdown = {} + for key in ("user", "nice", "system", "iowait", "irq", "softirq", "steal"): + breakdown[key] = _pct( + max(0, aggregate.get(key, 0) - prev.get(key, 0)), total_delta + ) + per_core = [] + prev_cores = {c["index"]: c for c in self._prev_cpu_cores} + for core in cores: + old = prev_cores.get(core["index"]) + if not old: + per_core.append(0.0) + continue + dt = max(0, core["total"] - old["total"]) + di = max(0, core["idle_all"] - old["idle_all"]) + per_core.append(_pct(dt - di, dt)) + + self._prev_cpu = aggregate + self._prev_cpu_cores = cores + meta = self._read_cpu_meta(now) + return { + "percent": _round(percent, 1), + "per_core": [_round(v, 1) for v in per_core], + "breakdown": {k: _round(v, 1) for k, v in breakdown.items()}, + "freq_mhz": meta["freq_mhz"], + "temp_c": meta["temp_c"], + "temp_label": meta["temp_label"], + "model": self._static["cpu_model"], + "cores": len(cores) or self._static["cores"], + "physical_cores": self._static["physical_cores"], + } + + def _read_cpu_meta(self, now: float) -> Dict[str, Any]: + if now - self._cpu_meta_ts < 3.0 and self._cpu_meta_ts: + return self._cpu_meta + + freqs: List[float] = [] + for path in glob.glob(str(self.sys_root / "devices/system/cpu/cpu*/cpufreq/scaling_cur_freq")): + try: + freqs.append(int(Path(path).read_text().strip()) / 1000.0) + except (OSError, ValueError): + continue + if not freqs: + for line in _read_text(self.proc_root / "cpuinfo").splitlines(): + if line.lower().startswith("cpu mhz"): + try: + freqs.append(float(line.split(":", 1)[1].strip())) + except (IndexError, ValueError): + pass + freq = round(sum(freqs) / len(freqs)) if freqs else None + + zones: List[Tuple[float, str]] = [] + for path in glob.glob(str(self.sys_root / "class/thermal/thermal_zone*")): + zone = Path(path) + raw_text = _read_text(zone / "temp").strip() + try: + raw = float(raw_text) + except ValueError: + continue + celsius = raw / 1000.0 if raw > 1000 else raw + if not -20.0 <= celsius <= 150.0: + continue + label = _read_text(zone / "type").strip() or zone.name + zones.append((celsius, label)) + if not zones: + for path in glob.glob(str(self.sys_root / "class/hwmon/hwmon*/temp*_input")): + pw = Path(path) + name = _read_text(pw.parent / "name").strip() + label = _read_text(pw.with_name(pw.stem.replace("_input", "_label"))).strip() or name + if not any(token in f"{name} {label}".lower() for token in ("cpu", "package", "tctl", "tdie", "core")): + continue + try: + raw = float(_read_text(pw).strip()) + except ValueError: + continue + celsius = raw / 1000.0 if raw > 1000 else raw + if -20.0 <= celsius <= 150.0: + zones.append((celsius, label)) + if zones: + preferred = [z for z in zones if any(token in z[1].lower() for token in ("cpu", "package", "tctl", "tdie", "core"))] + temp_c, temp_label = max(preferred or zones, key=lambda z: z[0]) + temp_c = round(temp_c, 1) + else: + temp_c, temp_label = None, None + + self._cpu_meta = {"freq_mhz": freq, "temp_c": temp_c, "temp_label": temp_label} + self._cpu_meta_ts = now + return self._cpu_meta + + def _read_memory(self) -> Dict[str, Any]: + info = parse_meminfo(_read_text(self.proc_root / "meminfo")) + total = info.get("total", 0) + free = info.get("free", 0) + available = info.get("available") or free + buffers = info.get("buffers", 0) + cached = info.get("cached", 0) + info.get("sreclaimable", 0) + swap_total = info.get("swap_total", 0) + swap_free = info.get("swap_free", 0) + + used = max(0, total - available) if total else 0 + percent = _pct(used, total) + swap_used = max(0, swap_total - swap_free) + swap_percent = _pct(swap_used, swap_total) + + apps = max(0, total - free - buffers - cached) + cache_value = min(cached, max(0, total - free - buffers - apps)) + composition = { + "apps": apps, + "cache": max(0, cache_value), + "buffers": buffers, + "free": free, + } + comp_total = sum(composition.values()) + if comp_total > total and comp_total > 0: + scale = total / comp_total + composition = {k: int(v * scale) for k, v in composition.items()} + + return { + "total": total, + "used": used, + "free": free, + "available": available, + "buffers": buffers, + "cached": cached, + "apps": apps, + "percent": _round(percent, 1), + "composition": composition, + "swap_total": swap_total, + "swap_used": swap_used, + "swap_free": swap_free, + "swap_percent": _round(swap_percent, 1), + "dirty": info.get("dirty", 0), + "writeback": info.get("writeback", 0), + } + + def _read_load(self, cores: int) -> Dict[str, Any]: + load = parse_loadavg(_read_text(self.proc_root / "loadavg")) + load["percent"] = _round(_pct(load["load1"], max(1, cores)), 1) + return load + + # ------------------------------------------------------------------ + # Disks + # ------------------------------------------------------------------ + def _stat_disk(self, mount: Dict[str, str]) -> Optional[Dict[str, Any]]: + mountpoint = mount["mount"] + try: + stat = os.statvfs(mountpoint) + except OSError: + return None + frsize = stat.f_frsize or stat.f_bsize + total = frsize * stat.f_blocks + free = frsize * stat.f_bavail + used = total - frsize * stat.f_bfree + if total <= 0: + return None + if total < 1024 * 1024: + return None + percent = _pct(used, used + free) + inodes_total = stat.f_files + inodes_free = stat.f_ffree + return { + "device": mount["device"], + "mount": mountpoint, + "fstype": mount["fstype"], + "total": int(total), + "used": int(max(0, used)), + "free": int(max(0, free)), + "percent": _round(percent, 1), + "readonly": "ro" in mount["options"].split(","), + "inodes_total": int(inodes_total), + "inodes_free": int(inodes_free), + } + + def _read_disks(self, now: float) -> List[Dict[str, Any]]: + if now - self._disk_cache_ts < 5.0 and self._disk_cache_ts: + return self._disk_cache + + mounts = parse_mounts(_read_text(self.proc_root / "mounts")) + disks: List[Dict[str, Any]] = [] + seen: set = set() + for mount in mounts: + fstype = mount["fstype"].lower() + device = mount["device"] + is_real = ( + fstype in REAL_FS_TYPES + or device.startswith("/dev/") + or (fstype.startswith("fuse.") and fstype not in ("fuse.gvfsd-fuse", "fuse.lxcfs")) + ) + if not is_real: + continue + if mount["mount"] in ("/dev", "/proc", "/sys", "/run"): + continue + disk = self._stat_disk(mount) + if not disk: + continue + key = (disk["device"], disk["total"], disk["used"]) + if key in seen: + continue + seen.add(key) + disks.append(disk) + + disks.sort(key=lambda d: (0 if d["mount"] == "/" else 1, -d["percent"])) + self._disk_cache = disks + self._disk_cache_ts = now + return disks + + def _is_partition(self, name: str) -> bool: + try: + if (self.sys_root / "class" / "block" / name / "partition").exists(): + return True + except OSError: + pass + patterns = ( + r"^(sd|hd|vd|xvd)[a-z]+\d+$", + r"^nvme\d+n\d+p\d+$", + r"^mmcblk\d+p\d+$", + r"^md\d+p\d+$", + ) + return any(re.match(pattern, name) for pattern in patterns) + + def _read_disk_io(self, now: float) -> Dict[str, Any]: + stats = parse_diskstats(_read_text(self.proc_root / "diskstats")) + dt = (now - self._prev_disk_ts) if self._prev_disk_ts is not None else 0.0 + devices: List[Dict[str, Any]] = [] + new_prev: Dict[str, Dict[str, int]] = {} + + for name, current in stats.items(): + if name.startswith(("loop", "ram", "fd", "sr", "zram")): + continue + if self._is_partition(name): + continue + new_prev[name] = current + old = self._prev_disk.get(name) + if old and dt > 0: + read_sectors = max(0, current["sectors_read"] - old["sectors_read"]) + write_sectors = max(0, current["sectors_written"] - old["sectors_written"]) + reads = max(0, current["reads"] - old["reads"]) + writes = max(0, current["writes"] - old["writes"]) + read_ms = max(0, current["read_ms"] - old["read_ms"]) + write_ms = max(0, current["write_ms"] - old["write_ms"]) + io_ms = max(0, current["io_ms"] - old["io_ms"]) + devices.append({ + "device": name, + "read_bps": read_sectors * 512.0 / dt, + "write_bps": write_sectors * 512.0 / dt, + "read_iops": reads / dt, + "write_iops": writes / dt, + "util_percent": min(100.0, io_ms / (dt * 1000.0) * 100.0), + "read_await_ms": read_ms / reads if reads else 0.0, + "write_await_ms": write_ms / writes if writes else 0.0, + }) + elif current["sectors_read"] or current["sectors_written"]: + devices.append({ + "device": name, + "read_bps": 0.0, + "write_bps": 0.0, + "read_iops": 0.0, + "write_iops": 0.0, + "util_percent": 0.0, + "read_await_ms": 0.0, + "write_await_ms": 0.0, + }) + + self._prev_disk = new_prev + self._prev_disk_ts = now + devices.sort(key=lambda d: d["read_bps"] + d["write_bps"], reverse=True) + devices = devices[:8] + for item in devices: + item["read_bps"] = round(item["read_bps"]) + item["write_bps"] = round(item["write_bps"]) + item["read_iops"] = round(item["read_iops"], 1) + item["write_iops"] = round(item["write_iops"], 1) + item["util_percent"] = round(item["util_percent"], 1) + item["read_await_ms"] = round(item["read_await_ms"], 2) + item["write_await_ms"] = round(item["write_await_ms"], 2) + return { + "devices": devices, + "read_bps": sum(d["read_bps"] for d in devices), + "write_bps": sum(d["write_bps"] for d in devices), + } + + # ------------------------------------------------------------------ + # Network + # ------------------------------------------------------------------ + def _iface_speed(self, name: str) -> Optional[int]: + try: + raw = _read_text(self.sys_root / "class" / "net" / name / "speed").strip() + value = int(raw) + return value if value > 0 else None + except (ValueError, OSError): + return None + + def _read_network(self, now: float) -> Dict[str, Any]: + stats = parse_net_dev(_read_text(self.proc_root / "net" / "dev")) + addresses = self._addresses(now) + dt = (now - self._prev_net_ts) if self._prev_net_ts is not None else 0.0 + interfaces: List[Dict[str, Any]] = [] + default_iface = self._default_interface() + + for name, current in stats.items(): + if name == "lo": + continue + old = self._prev_net.get(name) + rx_bps = (current["rx_bytes"] - old["rx_bytes"]) / dt if old and dt > 0 else 0.0 + tx_bps = (current["tx_bytes"] - old["tx_bytes"]) / dt if old and dt > 0 else 0.0 + rx_bps = max(0.0, rx_bps) + tx_bps = max(0.0, tx_bps) + is_virtual = name.startswith(VIRTUAL_IFACE_PREFIXES) + ips = addresses["interfaces"].get(name, []) + if not ips and name == default_iface and addresses.get("primary"): + ips = [addresses["primary"]] + interfaces.append({ + "name": name, + "ip": ips[0] if ips else "", + "virtual": is_virtual, + "primary": name == default_iface, + "rx_bps": rx_bps, + "tx_bps": tx_bps, + "rx_total": current["rx_bytes"], + "tx_total": current["tx_bytes"], + "errors": current["rx_errors"] + current["tx_errors"], + "drops": current["rx_drops"] + current["tx_drops"], + "speed_mbps": self._iface_speed(name), + "link": bool(ips) or rx_bps > 0 or tx_bps > 0, + }) + + physical = [i for i in interfaces if not i["virtual"]] + aggregate_source = physical or interfaces + total_rx = sum(i["rx_bps"] for i in aggregate_source) + total_tx = sum(i["tx_bps"] for i in aggregate_source) + + for item in interfaces: + item["rx_bps"] = round(item["rx_bps"]) + item["tx_bps"] = round(item["tx_bps"]) + interfaces.sort(key=lambda i: (not i["primary"], i["virtual"], -(i["rx_bps"] + i["tx_bps"]))) + self._prev_net = stats + self._prev_net_ts = now + + return { + "interfaces": interfaces[:8], + "interface_count": len(interfaces), + "total_rx_bps": round(total_rx), + "total_tx_bps": round(total_tx), + "primary_iface": default_iface, + "primary_ip": addresses.get("primary"), + "all_ips": addresses.get("all", []), + "errors": sum(i["errors"] for i in interfaces), + "drops": sum(i["drops"] for i in interfaces), + } + + # ------------------------------------------------------------------ + # Processes + # ------------------------------------------------------------------ + def _read_processes(self, now: float, mem_total: int) -> List[Dict[str, Any]]: + if self._proc_cache_ts and now - self._proc_cache_ts < 2.0: + return self._proc_cache + + pids: List[int] = [] + try: + with os.scandir(self.proc_root) as entries: + for entry in entries: + if entry.is_dir(follow_symlinks=False) and entry.name.isdigit(): + pids.append(int(entry.name)) + except OSError: + return self._proc_cache + + total_jiffies = self._cpu_total_now + delta_total = max(0, total_jiffies - self._prev_proc_total) + previous = self._proc_ticks + new_ticks: Dict[int, int] = {} + candidates: List[Dict[str, Any]] = [] + + for pid in pids: + text = _read_text(self.proc_root / str(pid) / "stat") + match = _STAT_RE.match(text) + if not match: + continue + try: + rest = match.group(4).split() + utime = int(rest[10]) + stime = int(rest[11]) + rss_pages = int(rest[20]) + threads = int(rest[16]) + except (IndexError, ValueError): + continue + ticks = utime + stime + new_ticks[pid] = ticks + old_ticks = previous.get(pid) + cpu_percent = 0.0 + if old_ticks is not None and delta_total > 0: + cpu_percent = max(0.0, (ticks - old_ticks) / delta_total * self._ncpu * 100.0) + rss = max(0, rss_pages) * self._page_size + candidates.append({ + "pid": pid, + "name": match.group(2).strip(), + "cmd": "", + "cpu_percent": cpu_percent, + "mem_percent": _pct(rss, mem_total), + "rss_bytes": rss, + "threads": threads, + }) + + self._proc_ticks = new_ticks + self._prev_proc_total = total_jiffies + + by_cpu = sorted(candidates, key=lambda p: p["cpu_percent"], reverse=True)[:8] + by_mem = sorted(candidates, key=lambda p: p["rss_bytes"], reverse=True)[:8] + selected: List[Dict[str, Any]] = [] + seen = set() + for item in by_cpu + by_mem: + if item["pid"] in seen: + continue + seen.add(item["pid"]) + selected.append(item) + if len(selected) >= 12: + break + + for item in selected: + raw = _read_text(self.proc_root / str(item["pid"]) / "cmdline") + cmd = " ".join(part for part in raw.split("\x00") if part).strip() + if not cmd: + cmd = item["name"] + item["cmd"] = cmd[:90] + item["cpu_percent"] = round(item["cpu_percent"], 1) + item["mem_percent"] = round(item["mem_percent"], 2) + + selected.sort(key=lambda p: p["cpu_percent"], reverse=True) + self._proc_cache = selected + self._proc_cache_ts = now + return selected + + # ------------------------------------------------------------------ + # Snapshot + # ------------------------------------------------------------------ + def snapshot(self) -> Dict[str, Any]: + now = self.clock() + wall = self.wall_clock() + cpu = self._read_cpu(now) + memory = self._read_memory() + load = self._read_load(cpu["cores"] or self._static["cores"]) + uptime = parse_uptime(_read_text(self.proc_root / "uptime")) + disks = self._read_disks(now) + disk_io = self._read_disk_io(now) + network = self._read_network(now) + addresses = self._addresses(now) + processes = self._read_processes(now, memory["total"]) + + alerts = build_alerts( + cpu_percent=float(cpu["percent"]), + memory_percent=float(memory["percent"]), + swap_percent=float(memory["swap_percent"]), + load1=float(load["load1"]), + cores=int(cpu["cores"] or self._static["cores"]), + disks=disks, + temp_c=cpu["temp_c"], + ) + + host = dict(self._static) + local_now = dt.datetime.now().astimezone() + local_offset = local_now.utcoffset() + host.update({ + "tz_offset": int(local_offset.total_seconds()) if local_offset else host["tz_offset"], + "tz_name": local_now.tzname() or host["tz_name"], + "boot_epoch": wall - uptime, + "uptime_seconds": round(uptime, 1), + "primary_ip": addresses.get("primary"), + "ips": addresses.get("all", []), + "process_count": len(self._proc_ticks), + "thread_count": load.get("threads", 0), + }) + + return { + "ts": wall, + "version": __version__, + "host": host, + "cpu": cpu, + "memory": memory, + "load": load, + "disks": disks, + "disk_io": disk_io, + "network": network, + "processes": processes, + "alerts": alerts, + } \ No newline at end of file diff --git a/servermon/service.py b/servermon/service.py new file mode 100644 index 0000000..23502e4 --- /dev/null +++ b/servermon/service.py @@ -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), + } \ No newline at end of file diff --git a/servermon/web.py b/servermon/web.py new file mode 100644 index 0000000..2d321a7 --- /dev/null +++ b/servermon/web.py @@ -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) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..edd7151 --- /dev/null +++ b/tests/test_metrics.py @@ -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() \ No newline at end of file diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ad654bd --- /dev/null +++ b/web/app.js @@ -0,0 +1,1273 @@ +(function () { + 'use strict'; + + var $ = function (id) { return document.getElementById(id); }; + var pages = Array.prototype.slice.call(document.querySelectorAll('.page')); + var PAGES = [ + { title: '系统概览', sub: 'SYSTEM OVERVIEW' }, + { title: '处理器与负载', sub: 'CPU & LOAD' }, + { title: '内存', sub: 'MEMORY' }, + { title: '存储', sub: 'STORAGE' }, + { title: '网络', sub: 'NETWORK' } + ]; + var WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + var COLORS = { + cyan: '#52e6cf', + blue: '#5aa9ff', + amber: '#ffb45c', + red: '#ff6072', + violet: '#b58cff', + green: '#62e6a8' + }; + + var params = new URLSearchParams(window.location.search); + var initialPage = parseInt(params.get('page') || '1', 10) - 1; + if (!isFinite(initialPage)) initialPage = 0; + initialPage = clamp(initialPage, 0, PAGES.length - 1); + var rotateParam = parseFloat(params.get('rotate') || '10'); + if (!isFinite(rotateParam)) rotateParam = 10; + + var state = { + data: null, + page: initialPage, + paused: /^(1|true|yes)$/i.test(params.get('paused') || ''), + rotateMs: clamp(rotateParam, 4, 120) * 1000, + remaining: clamp(rotateParam, 4, 120) * 1000, + startedAt: 0, + rotateTimer: null, + progressTimer: null, + anchorTs: 0, + anchorPerf: 0, + tzOffset: 0, + lastDataAt: 0, + es: null, + pollTimer: null, + pollInFlight: false, + reconnectTimer: null, + tickerSized: false, + tickerText: '', + visibilityPaused: false, + cursorTimer: null, + source: '' + }; + + var sparks = {}; + var stage = $('stage'); + + /* ------------------------------------------------------------------ */ + /* Formatting */ + /* ------------------------------------------------------------------ */ + function clamp(value, min, max) { + value = Number(value); + if (!isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); + } + + function num(value, fallback) { + var n = Number(value); + return isFinite(n) ? n : (fallback || 0); + } + + function pad2(value) { + value = Math.floor(value); + return value < 10 ? '0' + value : String(value); + } + + function fmtBytes(value, forceDigits) { + var n = Number(value); + if (!isFinite(n) || n <= 0) return '0 B'; + var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + var index = 0; + while (n >= 1024 && index < units.length - 1) { + n /= 1024; + index += 1; + } + var digits = typeof forceDigits === 'number' + ? forceDigits + : (n >= 100 ? 0 : n >= 10 ? 1 : 2); + return n.toFixed(digits) + ' ' + units[index]; + } + + function fmtRate(value) { + var n = Number(value); + if (!isFinite(n) || n <= 0.5) return '0 B/s'; + return fmtBytes(n) + '/s'; + } + + function fmtPct(value, digits) { + var n = Number(value); + if (!isFinite(n)) return '--'; + return n.toFixed(typeof digits === 'number' ? digits : 0) + '%'; + } + + function fmtNumber(value) { + var n = Number(value); + if (!isFinite(n)) return '--'; + return n.toLocaleString('en-US'); + } + + function fmtSpeed(mbps) { + if (!mbps || !isFinite(Number(mbps))) return '--'; + var n = Number(mbps); + if (n >= 1000) return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1) + ' Gbps'; + return n.toFixed(0) + ' Mbps'; + } + + function fmtUptime(seconds) { + var s = Math.max(0, Math.floor(Number(seconds) || 0)); + var days = Math.floor(s / 86400); + var hours = Math.floor((s % 86400) / 3600); + var minutes = Math.floor((s % 3600) / 60); + var secs = s % 60; + return (days > 0 ? days + 'd ' : '') + pad2(hours) + ':' + pad2(minutes) + ':' + pad2(secs); + } + + function formatOffset(seconds) { + var offset = Number(seconds) || 0; + var sign = offset >= 0 ? '+' : '-'; + offset = Math.abs(offset); + var hours = Math.floor(offset / 3600); + var minutes = Math.floor((offset % 3600) / 60); + return 'UTC' + sign + pad2(hours) + ':' + pad2(minutes); + } + + function serverNowMs() { + if (!state.anchorTs) return 0; + return state.anchorTs * 1000 + (performance.now() - state.anchorPerf) + state.tzOffset * 1000; + } + + function timeParts(ms) { + var date = new Date(ms); + return { + year: date.getUTCFullYear(), + month: pad2(date.getUTCMonth() + 1), + day: pad2(date.getUTCDate()), + hour: pad2(date.getUTCHours()), + minute: pad2(date.getUTCMinutes()), + second: pad2(date.getUTCSeconds()), + weekday: WEEKDAYS[date.getUTCDay()] + }; + } + + function formatStamp(epochSeconds) { + if (!epochSeconds) return '--'; + var p = timeParts((Number(epochSeconds) + state.tzOffset) * 1000); + return p.year + '-' + p.month + '-' + p.day + ' ' + p.hour + ':' + p.minute; + } + + function currentClockLabel() { + var ms = serverNowMs(); + if (!ms) return '--:--:--'; + var p = timeParts(ms); + return p.hour + ':' + p.minute + ':' + p.second; + } + + function escapeHtml(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]; + }); + } + + function setText(id, value) { + var el = $(id); + if (el) el.textContent = value == null ? '--' : String(value); + } + + function setHtml(id, value) { + var el = $(id); + if (el) el.innerHTML = value; + } + + function rgba(hex, alpha) { + var clean = String(hex).replace('#', ''); + if (clean.length === 3) clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2]; + var bigint = parseInt(clean, 16); + var r = (bigint >> 16) & 255; + var g = (bigint >> 8) & 255; + var b = bigint & 255; + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; + } + + function toneForPct(value) { + var pct = Number(value) || 0; + if (pct >= 95) return 'crit'; + if (pct >= 85) return 'warn'; + return 'ok'; + } + + function toneColor(tone, okColor) { + if (tone === 'crit') return COLORS.red; + if (tone === 'warn') return COLORS.amber; + return okColor || COLORS.cyan; + } + + function setTone(el, tone) { + if (!el) return; + el.classList.remove('tone-warn', 'tone-crit'); + if (tone === 'warn') el.classList.add('tone-warn'); + if (tone === 'crit') el.classList.add('tone-crit'); + } + + function setRing(id, pct, tone) { + var ring = $(id); + if (!ring) return; + ring.style.setProperty('--pct', String(clamp(pct, 0, 100))); + ring.setAttribute('data-tone', tone || 'ok'); + } + + function paintBar(el, pct, color, glow) { + if (!el) return; + el.style.setProperty('--w', clamp(pct, 0, 100).toFixed(1) + '%'); + if (color) el.style.setProperty('--bar-color', color); + if (glow) el.style.setProperty('--bar-glow', glow); + } + + function maxOf(values) { + var best = 0; + (values || []).forEach(function (v) { + var n = Number(v); + if (isFinite(n) && n > best) best = n; + }); + return best; + } + + function minOf(values) { + var best = Infinity; + (values || []).forEach(function (v) { + var n = Number(v); + if (isFinite(n) && n < best) best = n; + }); + return isFinite(best) ? best : 0; + } + + function meanOf(values) { + var total = 0; + var count = 0; + (values || []).forEach(function (v) { + var n = Number(v); + if (isFinite(n)) { + total += n; + count += 1; + } + }); + return count ? total / count : 0; + } + + /* ------------------------------------------------------------------ */ + /* Sparklines */ + /* ------------------------------------------------------------------ */ + function Spark(canvas, options) { + this.canvas = canvas; + this.ctx = canvas ? canvas.getContext('2d') : null; + this.options = options || {}; + this.options.series = this.options.series || [{ key: 'value', color: COLORS.cyan }]; + this.data = {}; + this.width = 0; + this.height = 0; + this.raf = 0; + this.resize = this.resize.bind(this); + this.draw = this.draw.bind(this); + this.resize(); + if (window.ResizeObserver && canvas) { + this.observer = new ResizeObserver(this.resize); + this.observer.observe(canvas); + } + } + + Spark.prototype.setData = function (data) { + if (Array.isArray(data)) { + var wrapped = {}; + wrapped[this.options.series[0].key] = data; + this.data = wrapped; + } else { + this.data = data || {}; + } + this.requestDraw(); + }; + + Spark.prototype.setOptions = function (options) { + var key; + for (key in options) { + if (Object.prototype.hasOwnProperty.call(options, key)) this.options[key] = options[key]; + } + this.requestDraw(); + }; + + Spark.prototype.resize = function () { + if (!this.canvas || !this.ctx) return; + var width = this.canvas.clientWidth; + var height = this.canvas.clientHeight; + if (!width || !height) return; + var dpr = Math.min(window.devicePixelRatio || 1, 2); + var pixelWidth = Math.round(width * dpr); + var pixelHeight = Math.round(height * dpr); + if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) { + this.canvas.width = pixelWidth; + this.canvas.height = pixelHeight; + } + this.width = width; + this.height = height; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.draw(); + }; + + Spark.prototype.requestDraw = function () { + var self = this; + if (this.raf) return; + this.raf = window.requestAnimationFrame(function () { + self.raf = 0; + self.draw(); + }); + }; + + Spark.prototype.draw = function () { + if (!this.ctx || !this.width || !this.height) return; + var ctx = this.ctx; + var w = this.width; + var h = this.height; + var padTop = 5; + var padBottom = 2; + var plotHeight = Math.max(1, h - padTop - padBottom); + var series = this.options.series; + var allValues = []; + var i; + var j; + + ctx.clearRect(0, 0, w, h); + + for (i = 0; i < series.length; i += 1) { + var values = this.data[series[i].key] || []; + for (j = 0; j < values.length; j += 1) { + var value = Number(values[j]); + if (isFinite(value)) allValues.push(value); + } + } + + var dataMax = allValues.length ? Math.max.apply(null, allValues) : 0; + var max = Number(this.options.max); + if (!isFinite(max) || max <= 0) max = Math.max(dataMax * 1.12, 1); + var min = Number(this.options.min); + if (!isFinite(min)) min = 0; + if (max <= min) max = min + 1; + + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(118, 160, 202, .07)'; + for (i = 0; i <= 3; i += 1) { + var gy = padTop + (plotHeight * i / 3); + ctx.beginPath(); + ctx.moveTo(0, Math.round(gy) + 0.5); + ctx.lineTo(w, Math.round(gy) + 0.5); + ctx.stroke(); + } + + if (this.options.threshold && Number(this.options.threshold) < max) { + var ty = padTop + (1 - (Number(this.options.threshold) - min) / (max - min)) * plotHeight; + ctx.save(); + ctx.setLineDash([3, 4]); + ctx.strokeStyle = 'rgba(255, 180, 92, .2)'; + ctx.beginPath(); + ctx.moveTo(0, ty); + ctx.lineTo(w, ty); + ctx.stroke(); + ctx.restore(); + } + + if (!allValues.length) { + ctx.fillStyle = 'rgba(113, 143, 170, .55)'; + ctx.font = '7px "JetBrains Mono", monospace'; + ctx.textAlign = 'center'; + ctx.fillText('WAITING FOR SAMPLE', w / 2, h / 2 + 3); + return; + } + + for (i = 0; i < series.length; i += 1) { + var spec = series[i]; + var raw = this.data[spec.key] || []; + var points = []; + var count = raw.length; + if (!count) continue; + if (count === 1) { + var only = clamp(num(raw[0], min), min, max); + points.push({ x: 0, y: padTop + (1 - (only - min) / (max - min)) * plotHeight }); + points.push({ x: w, y: padTop + (1 - (only - min) / (max - min)) * plotHeight }); + } else { + for (j = 0; j < count; j += 1) { + var v = clamp(num(raw[j], min), min, max); + points.push({ + x: (j / (count - 1)) * w, + y: padTop + (1 - (v - min) / (max - min)) * plotHeight + }); + } + } + + var color = spec.color || COLORS.cyan; + var gradient = ctx.createLinearGradient(0, padTop, 0, h); + gradient.addColorStop(0, rgba(color, spec.alpha || 0.18)); + gradient.addColorStop(1, rgba(color, 0)); + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (j = 1; j < points.length; j += 1) { + var prev = points[j - 1]; + var cur = points[j]; + var mid = (prev.x + cur.x) / 2; + ctx.bezierCurveTo(mid, prev.y, mid, cur.y, cur.x, cur.y); + } + ctx.lineTo(points[points.length - 1].x, h); + ctx.lineTo(points[0].x, h); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (j = 1; j < points.length; j += 1) { + var p0 = points[j - 1]; + var p1 = points[j]; + var middle = (p0.x + p1.x) / 2; + ctx.bezierCurveTo(middle, p0.y, middle, p1.y, p1.x, p1.y); + } + ctx.strokeStyle = color; + ctx.lineWidth = series.length > 1 ? 1.25 : 1.45; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.shadowColor = rgba(color, 0.55); + ctx.shadowBlur = 4; + ctx.stroke(); + ctx.shadowBlur = 0; + + var last = points[points.length - 1]; + ctx.beginPath(); + ctx.arc(last.x - 0.5, last.y, 2.1, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.shadowColor = rgba(color, 0.85); + ctx.shadowBlur = 7; + ctx.fill(); + ctx.shadowBlur = 0; + } + }; + + /* ------------------------------------------------------------------ */ + /* Clock */ + /* ------------------------------------------------------------------ */ + function tickClock() { + if (!state.anchorTs) return; + var ms = serverNowMs(); + var p = timeParts(ms); + setText('clock', p.hour + ':' + p.minute + ':' + p.second); + setText('date', p.year + '-' + p.month + '-' + p.day + ' ' + p.weekday); + setText('timezone', formatOffset(state.tzOffset)); + setText('ticker-update', p.hour + ':' + p.minute + ':' + p.second); + if (state.data && state.data.host) { + var elapsed = (performance.now() - state.anchorPerf) / 1000; + setText('uptime', fmtUptime(num(state.data.host.uptime_seconds, 0) + elapsed)); + } + } + + /* ------------------------------------------------------------------ */ + /* Renderers */ + /* ------------------------------------------------------------------ */ + function renderHost(data) { + var host = data.host || {}; + var meta = data.meta || {}; + setText('hostname', host.hostname || '--'); + setText('os-name', host.os || '--'); + setText('kernel', host.kernel || '--'); + setText('arch', host.arch || '--'); + setText('primary-ip', host.primary_ip || '--'); + setText('sys-os', host.os || '--'); + setText('sys-kernel', host.kernel || '--'); + setText('sys-arch', host.arch || '--'); + setText('sys-ip', host.primary_ip || '--'); + setText('sys-procs', fmtNumber(host.process_count) + ' / ' + fmtNumber(host.thread_count)); + setText('version', 'v' + (data.version || '--')); + setText('sample-interval', (num(meta.sample_interval, 1)).toFixed(1) + 's'); + setText('ticker-sample', 'SAMPLE ' + (num(meta.sample_interval, 1)).toFixed(1) + 's'); + setText('foot-mode', meta.demo ? 'DEMO 演示' : 'LIVE 实时'); + setText('foot-sample', 'UPDATE ' + currentClockLabel() + ' · ' + (num(meta.sample_interval, 1)).toFixed(1) + 's'); + var demoPill = $('demo-pill'); + if (demoPill) demoPill.hidden = !meta.demo; + if (host.boot_epoch) setText('boot-time', 'BOOT ' + formatStamp(host.boot_epoch)); + else setText('boot-time', 'BOOT --'); + } + + function renderCpu(data) { + var cpu = data.cpu || {}; + var history = data.history || {}; + var pct = clamp(num(cpu.percent, 0), 0, 100); + var tone = toneForPct(pct); + var cores = cpu.cores || (data.host && data.host.cores) || 0; + var physical = cpu.physical_cores || (data.host && data.host.physical_cores) || cores; + var model = cpu.model || (data.host && data.host.cpu_model) || '--'; + var freq = cpu.freq_mhz ? Math.round(cpu.freq_mhz) + ' MHz' : '--'; + var temp = (cpu.temp_c === null || cpu.temp_c === undefined) ? '--' : Number(cpu.temp_c).toFixed(1) + '°C'; + var peak = maxOf(history.cpu); + + setHtml('cpu-percent', String(Math.round(pct)) + '%'); + setHtml('cpu-percent-2', String(Math.round(pct)) + '%'); + setRing('cpu-ring', pct, tone); + setRing('cpu-ring-2', pct, tone); + + setText('cpu-badge', String(cores) + ' THREADS'); + setText('cpu-badge-2', String(cores) + ' THREADS'); + setText('cpu-model', model); + setText('cpu-model-2', model); + setText('cpu-freq', freq); + setText('cpu-freq-2', freq); + setText('cpu-temp', temp); + setText('cpu-temp-2', temp); + setText('cpu-cores', String(cores) + ' / ' + String(physical)); + setText('cpu-cores-2', String(cores) + ' / ' + String(physical)); + setText('cpu-peak', 'PEAK ' + fmtPct(peak)); + setText('cpu-peak-2', 'PEAK ' + fmtPct(peak)); + + var breakdown = cpu.breakdown || {}; + setText('bd-user-val', fmtPct(num(breakdown.user), 1)); + setText('bd-system-val', fmtPct(num(breakdown.system), 1)); + setText('bd-iowait-val', fmtPct(num(breakdown.iowait), 1)); + setText('bd-steal-val', fmtPct(num(breakdown.steal), 1)); + paintBar($('bd-user'), num(breakdown.user), COLORS.cyan, rgba(COLORS.cyan, 0.3)); + paintBar($('bd-system'), num(breakdown.system), COLORS.blue, rgba(COLORS.blue, 0.3)); + paintBar($('bd-iowait'), num(breakdown.iowait), COLORS.amber, rgba(COLORS.amber, 0.3)); + paintBar($('bd-steal'), num(breakdown.steal), COLORS.violet, rgba(COLORS.violet, 0.3)); + + renderCores(cpu.per_core || []); + } + + function renderCores(values) { + var grid = $('core-grid'); + if (!grid) return; + var cores = values.slice(0, 16); + setText('core-count', String(values.length || 0) + ' CORES'); + if (!cores.length) { + grid.innerHTML = '
等待每核心数据…
'; + return; + } + var columns = Math.min(8, cores.length); + grid.style.gridTemplateColumns = 'repeat(' + columns + ', minmax(0, 1fr))'; + var html = ''; + for (var i = 0; i < cores.length; i += 1) { + var value = clamp(num(cores[i]), 0, 100); + var tone = toneForPct(value); + html += '
' + + '
C' + (i < 10 ? '0' + i : i) + '' + Math.round(value) + '%
' + + '
' + + '
'; + } + grid.innerHTML = html; + } + + function renderMemory(data) { + var memory = data.memory || {}; + var history = data.history || {}; + var total = num(memory.total); + var used = num(memory.used); + var available = num(memory.available); + var pct = clamp(num(memory.percent), 0, 100); + var tone = toneForPct(pct); + var peak = maxOf(history.mem); + var avg = meanOf(history.mem); + var swapPct = clamp(num(memory.swap_percent), 0, 100); + + setHtml('mem-percent', String(Math.round(pct)) + '%'); + setHtml('mem-percent-2', String(Math.round(pct)) + '%'); + setRing('mem-ring', pct, tone); + setRing('mem-ring-2', pct, tone); + + setText('mem-badge', 'AVAIL ' + fmtBytes(available)); + setText('mem-badge-2', 'AVAIL ' + fmtBytes(available)); + setText('mem-used', fmtBytes(used)); + setText('mem-total', fmtBytes(total)); + setText('mem-used-2', fmtBytes(used)); + setText('mem-total-2', fmtBytes(total)); + setText('mem-available', fmtBytes(available)); + setText('mem-available-2', fmtBytes(available)); + setText('mem-cache', fmtBytes(memory.cached)); + setText('mem-cache-2', fmtBytes(memory.cached)); + setText('mem-buffers-2', fmtBytes(memory.buffers)); + + setText('mem-trend-badge', fmtPct(pct, 1)); + setText('mem-trend-current', fmtPct(pct, 1)); + setText('mem-trend-peak', fmtPct(peak, 1)); + setText('mem-trend-avg', fmtPct(avg, 1)); + setText('mem-trend-swap', fmtPct(swapPct, 1)); + setText('mem-peak-2', 'PEAK ' + fmtPct(peak, 1)); + + var comp = memory.composition || {}; + var parts = ['apps', 'cache', 'buffers', 'free']; + var suffixes = ['', '-2']; + suffixes.forEach(function (suffix) { + parts.forEach(function (key) { + var el = $('mem-seg-' + key + suffix); + if (!el) return; + var width = total > 0 ? (num(comp[key]) / total) * 100 : 0; + el.style.width = clamp(width, 0, 100).toFixed(2) + '%'; + }); + }); + + var memColors = { apps: COLORS.blue, cache: '#3e8ecf', buffers: '#305a7a', free: '#456176' }; + parts.forEach(function (key) { + var value = total > 0 ? (num(comp[key]) / total) * 100 : 0; + paintBar($('mem-bd-' + key), value, memColors[key], rgba(memColors[key], 0.28)); + setText('mem-bd-' + key + '-val', fmtPct(value, 1)); + }); + + paintBar($('swap-bar'), swapPct, swapPct >= 80 ? COLORS.red : swapPct >= 50 ? COLORS.amber : COLORS.blue, + rgba(swapPct >= 80 ? COLORS.red : swapPct >= 50 ? COLORS.amber : COLORS.blue, 0.35)); + setText('swap-text', fmtPct(swapPct, 1)); + setText('swap-text-2', fmtPct(swapPct, 1) + ' · ' + fmtBytes(memory.swap_used) + ' / ' + fmtBytes(memory.swap_total)); + setText('mem-trend-swap', fmtPct(swapPct, 1)); + } + + function renderLoad(data) { + var load = data.load || {}; + var cores = num((data.cpu && data.cpu.cores) || (data.host && data.host.cores), 1); + var load1 = num(load.load1); + var load5 = num(load.load5); + var load15 = num(load.load15); + var history = data.history || {}; + var peak = maxOf(history.load); + + setText('load1', load1.toFixed(2)); + setText('load5', load5.toFixed(2)); + setText('load15', load15.toFixed(2)); + setText('load-running', fmtNumber(load.running)); + setText('load-threads', fmtNumber(load.threads)); + setText('load-pct', fmtPct(load.percent, 0)); + setText('load-badge', String(cores) + ' CORES'); + setText('load-peak', 'PEAK ' + peak.toFixed(1)); + + var values = [load1, load5, load15]; + var ids = ['load1-bar', 'load5-bar', 'load15-bar']; + values.forEach(function (value, index) { + var barPct = cores > 0 ? (value / cores) * 100 : 0; + var tone = cores > 0 && value >= cores * 3 ? 'crit' : cores > 0 && value >= cores * 1.5 ? 'warn' : 'ok'; + var color = toneColor(tone, COLORS.amber); + paintBar($(ids[index]), barPct, color, rgba(color, 0.35)); + }); + setTone($('load1'), cores > 0 && load1 >= cores * 3 ? 'crit' : cores > 0 && load1 >= cores * 1.5 ? 'warn' : 'ok'); + } + + function diskColor(tone) { + return toneColor(tone, COLORS.violet); + } + + function diskRow(disk) { + var pct = clamp(num(disk.percent), 0, 100); + var tone = toneForPct(pct); + var color = diskColor(tone); + return '
' + + '
' + escapeHtml(disk.mount) + + '' + escapeHtml(disk.fstype || '') + '' + + '' + fmtPct(pct, 1) + '
' + + '
' + + '
' + fmtBytes(disk.used) + ' / ' + fmtBytes(disk.total) + + '' + fmtBytes(disk.free) + ' 可用
'; + } + + function renderDisks(data) { + var disks = data.disks || []; + setText('disk-count', disks.length + ' FS'); + var overview = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); }).slice(0, 4); + setHtml('disk-overview', overview.length + ? overview.map(diskRow).join('') + : '
未发现可监控的文件系统
'); + + var sorted = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); }); + setText('fs-badge', disks.length + ' FS'); + if (!sorted.length) { + setHtml('fs-body', '未发现可监控的文件系统'); + } else { + var html = ''; + sorted.slice(0, 5).forEach(function (disk) { + var pct = clamp(num(disk.percent), 0, 100); + var tone = toneForPct(pct); + var color = diskColor(tone); + html += '' + + '' + escapeHtml(disk.mount) + '' + (disk.readonly ? 'RO' : '') + '' + + '' + escapeHtml(disk.device) + '' + + '' + escapeHtml(disk.fstype) + '' + + '' + fmtBytes(disk.total) + '' + + '' + fmtBytes(disk.used) + '' + + '' + fmtBytes(disk.free) + '' + + '
' + + '' + fmtPct(pct, 1) + '' + + ''; + }); + setHtml('fs-body', html); + } + + var fsTotal = 0; + var fsUsed = 0; + var fsFree = 0; + disks.forEach(function (disk) { + fsTotal += num(disk.total); + fsUsed += num(disk.used); + fsFree += num(disk.free); + }); + var fsPct = (fsUsed + fsFree) > 0 ? (fsUsed / (fsUsed + fsFree)) * 100 : 0; + setText('fs-total', fmtBytes(fsTotal)); + setText('fs-used', fmtBytes(fsUsed)); + setText('fs-free', fmtBytes(fsFree)); + setText('fs-total-pct', fmtPct(fsPct, 1)); + paintBar($('fs-total-bar'), fsPct, COLORS.violet, rgba(COLORS.violet, 0.35)); + setText('fs-more', sorted.length > 5 ? '另有 ' + (sorted.length - 5) + ' 个文件系统' : '共 ' + sorted.length + ' 个文件系统'); + + var io = data.disk_io || {}; + setText('io-badge', 'R ' + fmtRate(io.read_bps) + ' · W ' + fmtRate(io.write_bps)); + var devices = (io.devices || []).slice(0, 5); + var allDevices = io.devices || []; + setText('io-device-count', String(allDevices.length)); + var busiest = allDevices.slice().sort(function (a, b) { return num(b.util_percent) - num(a.util_percent); })[0]; + setText('io-busiest', busiest ? busiest.device + ' · ' + fmtPct(busiest.util_percent, 0) : '—'); + setText('io-total-read', fmtRate(io.read_bps)); + setText('io-total-write', fmtRate(io.write_bps)); + setText('io-side-rate', fmtRate(num(io.read_bps) + num(io.write_bps))); + if (!devices.length) { + setHtml('io-body', '暂无块设备 I/O 活动'); + return; + } + var ioHtml = ''; + devices.forEach(function (device) { + var util = clamp(num(device.util_percent), 0, 100); + var tone = util >= 85 ? 'crit' : util >= 60 ? 'warn' : 'ok'; + var color = toneColor(tone, COLORS.blue); + ioHtml += '' + + '' + escapeHtml(device.device) + '' + + '' + fmtRate(device.read_bps) + '' + + '' + fmtRate(device.write_bps) + '' + + '' + num(device.read_iops).toFixed(0) + ' / ' + num(device.write_iops).toFixed(0) + '' + + '' + num(device.read_await_ms).toFixed(2) + ' / ' + num(device.write_await_ms).toFixed(2) + ' ms' + + '
' + + '' + fmtPct(util, 0) + '' + + ''; + }); + setHtml('io-body', ioHtml); + } + + function ifaceRow(iface) { + var virtual = !!iface.virtual; + var errors = num(iface.errors) + num(iface.drops); + var badge = iface.primary + ? 'PRIMARY' + : virtual ? 'VIRTUAL' : ''; + var link = iface.link ? fmtSpeed(iface.speed_mbps) : 'DOWN'; + return '' + + '
' + escapeHtml(iface.name) + + '' + badge + '
' + + '' + escapeHtml(iface.ip || '—') + '' + + '' + fmtRate(iface.rx_bps) + '' + + '' + fmtRate(iface.tx_bps) + '' + + '' + fmtBytes(iface.rx_total) + ' / ' + fmtBytes(iface.tx_total) + '' + + '' + fmtNumber(errors) + '' + + '' + (link === 'DOWN' ? 'DOWN' : link) + '' + + ''; + } + + function renderNetwork(data) { + var network = data.network || {}; + var ifaces = network.interfaces || []; + var rx = num(network.total_rx_bps); + var tx = num(network.total_tx_bps); + var count = num(network.interface_count, ifaces.length); + var rxTotal = 0; + var txTotal = 0; + ifaces.forEach(function (iface) { + rxTotal += num(iface.rx_total); + txTotal += num(iface.tx_total); + }); + + setText('net-rx', fmtRate(rx)); + setText('net-tx', fmtRate(tx)); + setText('net-rx-big', fmtRate(rx)); + setText('net-tx-big', fmtRate(tx)); + setText('net-rx-total', '累计 RX ' + fmtBytes(rxTotal)); + setText('net-tx-total', '累计 TX ' + fmtBytes(txTotal)); + setText('net-ifaces', String(count)); + setText('net-errors', fmtNumber(num(network.errors) + num(network.drops)) + ' 错误'); + setText('net-badge', String(count) + ' IFACES'); + setText('iface-badge', String(count) + ' IFACES'); + setText('net-sum-ifaces', String(count)); + setText('net-sum-errors', fmtNumber(network.errors)); + setText('net-sum-drops', fmtNumber(network.drops)); + setText('net-sum-primary', network.primary_iface || '—'); + + if (!ifaces.length) { + setHtml('iface-body', '未发现网络接口'); + } else { + setHtml('iface-body', ifaces.slice(0, 6).map(ifaceRow).join('')); + } + } + + function procRows(processes, mode, limit) { + if (!processes.length) { + return '暂无进程数据'; + } + var sorted = processes.slice().sort(function (a, b) { + return mode === 'mem' ? num(b.mem_percent) - num(a.mem_percent) : num(b.cpu_percent) - num(a.cpu_percent); + }).slice(0, limit); + return sorted.map(function (proc) { + var cpu = num(proc.cpu_percent); + var cpuClass = cpu >= 100 ? 'critical' : cpu >= 50 ? 'hot' : ''; + if (mode === 'mem') { + return '' + escapeHtml(proc.pid) + '' + + '
' + escapeHtml(proc.name) + '' + escapeHtml(proc.cmd || '') + '
' + + '' + fmtPct(proc.mem_percent, 1) + '' + + '' + fmtBytes(proc.rss_bytes) + '' + + '' + fmtPct(cpu, 1) + ''; + } + return '' + escapeHtml(proc.pid) + '' + + '
' + escapeHtml(proc.name) + '' + escapeHtml(proc.cmd || '') + '
' + + '' + fmtPct(cpu, 1) + '' + + '' + fmtPct(proc.mem_percent, 1) + '' + + '' + fmtBytes(proc.rss_bytes) + ''; + }).join(''); + } + + function renderProcesses(data) { + var processes = data.processes || []; + var host = data.host || {}; + setText('proc-count', fmtNumber(host.process_count) + ' PROCS'); + setText('mem-proc-count', fmtNumber(host.process_count) + ' PROCS'); + setHtml('proc-cpu-body', procRows(processes, 'cpu', 7)); + setHtml('proc-mem-body', procRows(processes, 'mem', 7)); + } + + /* ------------------------------------------------------------------ */ + /* Ticker */ + /* ------------------------------------------------------------------ */ + function renderTicker(data) { + var host = data.host || {}; + var cpu = data.cpu || {}; + var memory = data.memory || {}; + var load = data.load || {}; + var network = data.network || {}; + var disks = data.disks || []; + var alerts = data.alerts || []; + var topDisk = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); })[0]; + var level = alerts.some(function (a) { return a.severity === 'crit'; }) ? 'crit' + : alerts.length ? 'warn' : 'ok'; + var ticker = document.querySelector('.ticker'); + if (ticker) ticker.setAttribute('data-level', level); + + var alertText = alerts.length + ? '⚠ ' + alerts.slice(0, 3).map(function (alert) { return alert.message; }).join(' · ') + : '● 系统运行正常'; + var stats = 'CPU ' + fmtPct(cpu.percent, 0) + + ' · 内存 ' + fmtPct(memory.percent, 0) + + ' · 负载 ' + num(load.load1).toFixed(2) + + (topDisk ? ' · 磁盘 ' + topDisk.mount + ' ' + fmtPct(topDisk.percent, 0) : '') + + ' · 网络 ↓ ' + fmtRate(network.total_rx_bps) + ' ↑ ' + fmtRate(network.total_tx_bps); + var stamp = currentClockLabel(); + var text = alertText + ' /// ' + stats + ' /// ' + + (host.hostname || '') + ' · ' + stamp + ' · UPTIME ' + fmtUptime(host.uptime_seconds); + + if (text !== state.tickerText) { + state.tickerText = text; + var a = $('ticker-a'); + var b = $('ticker-b'); + if (a) a.textContent = text; + if (b) b.textContent = text; + if (!state.tickerSized) { + window.requestAnimationFrame(function () { + var width = a ? a.scrollWidth : 0; + if (width > 0) { + var duration = Math.max(38, Math.min(150, width / 24)); + var track = $('ticker-track'); + if (track) track.style.animationDuration = duration.toFixed(1) + 's'; + state.tickerSized = true; + } + }); + } + } + } + + function renderAll(data) { + renderHost(data); + renderCpu(data); + renderMemory(data); + renderLoad(data); + renderDisks(data); + renderNetwork(data); + renderProcesses(data); + renderTicker(data); + updateCharts(data); + } + + function updateCharts(data) { + var history = data.history || {}; + var cores = num((data.cpu && data.cpu.cores) || 16, 16); + sparks.cpu.setData({ cpu: history.cpu || [] }); + sparks.cpu2.setData({ cpu: history.cpu || [] }); + sparks.mem.setData({ mem: history.mem || [] }); + sparks.memLarge.setData({ mem: history.mem || [] }); + sparks.load.setOptions({ max: Math.max(cores * 2, maxOf(history.load) * 1.15, 1) }); + sparks.load.setData({ load: history.load || [] }); + var netData = { rx: history.rx || [], tx: history.tx || [] }; + sparks.net.setData(netData); + sparks.netBig.setData(netData); + sparks.io.setData({ read: history.disk_r || [], write: history.disk_w || [] }); + + var memValues = history.mem || []; + var memMax = memValues.length ? Math.min(100, Math.max(10, maxOf(memValues) + 4)) : 100; + var memMin = memValues.length ? Math.max(0, minOf(memValues) - 4) : 0; + sparks.mem.setOptions({ max: memMax, min: memMin }); + sparks.memLarge.setOptions({ max: memMax, min: memMin }); + } + + /* ------------------------------------------------------------------ */ + /* Link / data flow */ + /* ------------------------------------------------------------------ */ + function setLinkState(mode, label) { + var pill = $('live-pill'); + var led = $('foot-led'); + if (pill) { + pill.setAttribute('data-state', mode); + setText('live-text', label); + } + if (led) { + led.classList.toggle('offline', mode === 'offline'); + } + } + + function onData(data, source) { + if (!data || !data.host || !data.cpu) return; + var first = !state.data; + state.data = data; + state.lastDataAt = Date.now(); + state.source = source || 'sse'; + state.anchorTs = num(data.ts, Date.now() / 1000); + state.anchorPerf = performance.now(); + state.tzOffset = num((data.host || {}).tz_offset, 0); + + renderAll(data); + setLinkState('live', source === 'poll' ? '轮询' : 'LIVE'); + if (first) { + window.setTimeout(function () { refitCharts(); }, 60); + } + } + + function refitCharts() { + Object.keys(sparks).forEach(function (key) { + if (sparks[key] && sparks[key].resize) sparks[key].resize(); + }); + } + + function startPolling() { + if (state.pollTimer) return; + state.pollTimer = window.setInterval(pollSnapshot, 2000); + pollSnapshot(); + } + + function stopPolling() { + if (state.pollTimer) { + window.clearInterval(state.pollTimer); + state.pollTimer = null; + } + } + + function pollSnapshot() { + if (state.pollInFlight) return; + state.pollInFlight = true; + window.fetch('/api/snapshot', { cache: 'no-store', credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.json(); + }) + .then(function (data) { + onData(data, 'poll'); + }) + .catch(function () { + if (!state.data) setLinkState('offline', '离线'); + }) + .then(function () { + state.pollInFlight = false; + }); + } + + function connectStream() { + if (!('EventSource' in window)) { + startPolling(); + return; + } + if (state.es) { + try { state.es.close(); } catch (err) { /* ignore */ } + } + var es = new window.EventSource('/api/stream'); + state.es = es; + + es.onopen = function () { + if (state.reconnectTimer) { + window.clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + setLinkState('live', 'LIVE'); + stopPolling(); + }; + es.onmessage = function (event) { + var data; + try { + data = JSON.parse(event.data); + } catch (err) { + return; + } + stopPolling(); + onData(data, 'sse'); + if (state.reconnectTimer) { + window.clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + }; + es.onerror = function () { + setLinkState('connecting', '重连中'); + startPolling(); + if (es.readyState === 2 && !state.reconnectTimer) { + state.reconnectTimer = window.setTimeout(function () { + state.reconnectTimer = null; + connectStream(); + }, 5000); + } + }; + } + + function watchdog() { + if (!state.data) return; + var age = Date.now() - state.lastDataAt; + if (age > 8000) startPolling(); + if (age > 30000) setLinkState('offline', '离线'); + } + + /* ------------------------------------------------------------------ */ + /* Rotation / navigation */ + /* ------------------------------------------------------------------ */ + function startProgress(duration) { + var progress = $('page-progress'); + if (!progress) return; + progress.style.animation = 'none'; + void progress.offsetWidth; + progress.style.animation = 'page-progress-fill ' + duration + 'ms linear forwards'; + progress.style.animationPlayState = state.paused ? 'paused' : 'running'; + } + + function scheduleRotation(duration) { + window.clearTimeout(state.rotateTimer); + state.rotateTimer = null; + var ms = duration || state.rotateMs; + state.remaining = ms; + state.startedAt = performance.now(); + startProgress(ms); + if (state.paused) return; + state.rotateTimer = window.setTimeout(function () { + goTo((state.page + 1) % PAGES.length, true); + }, ms); + } + + function updateNav() { + setText('page-index', pad2(state.page + 1)); + setText('page-title', PAGES[state.page].title); + setText('page-sub', PAGES[state.page].sub); + var dots = document.querySelectorAll('.dot'); + for (var i = 0; i < dots.length; i += 1) { + dots[i].classList.toggle('is-active', i === state.page); + dots[i].setAttribute('aria-selected', i === state.page ? 'true' : 'false'); + dots[i].tabIndex = i === state.page ? 0 : -1; + } + } + + function goTo(index, fromTimer) { + index = ((index % PAGES.length) + PAGES.length) % PAGES.length; + if (index === state.page && !fromTimer) { + scheduleRotation(); + updatePauseUi(); + return; + } + var previous = state.page; + pages.forEach(function (page, i) { + page.classList.toggle('is-active', i === index); + page.classList.toggle('is-prev', i === previous && i !== index); + page.setAttribute('aria-hidden', i === index ? 'false' : 'true'); + }); + state.page = index; + updateNav(); + scheduleRotation(); + window.setTimeout(refitCharts, 80); + } + + function updatePauseUi() { + var nav = document.querySelector('.nav'); + if (nav) nav.classList.toggle('is-paused', state.paused); + setText('rotation-state', state.paused ? '已暂停' : '自动轮播'); + } + + function togglePause(force) { + var next = typeof force === 'boolean' ? force : !state.paused; + if (next === state.paused) return; + if (!state.paused && next) { + var elapsed = performance.now() - state.startedAt; + state.remaining = Math.max(1200, (state.remaining || state.rotateMs) - elapsed); + } + state.paused = next; + updatePauseUi(); + if (state.paused) { + window.clearTimeout(state.rotateTimer); + state.rotateTimer = null; + var progress = $('page-progress'); + if (progress) progress.style.animationPlayState = 'paused'; + } else { + scheduleRotation(state.remaining); + } + } + + function buildDots() { + var holder = $('dots'); + if (!holder) return; + PAGES.forEach(function (page, index) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'dot'; + button.textContent = pad2(index + 1); + button.setAttribute('role', 'tab'); + button.setAttribute('aria-label', page.title + ' / ' + page.sub); + button.title = page.title + ' · ' + page.sub; + button.addEventListener('click', function () { goTo(index, false); }); + holder.appendChild(button); + }); + } + + function initPages() { + pages.forEach(function (page, i) { + page.classList.toggle('is-active', i === state.page); + page.setAttribute('aria-hidden', i === state.page ? 'false' : 'true'); + }); + updateNav(); + updatePauseUi(); + scheduleRotation(); + } + + /* ------------------------------------------------------------------ */ + /* Interaction */ + /* ------------------------------------------------------------------ */ + function bindKeys() { + document.addEventListener('keydown', function (event) { + var tag = (event.target && event.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (event.code === 'Space' || event.key === ' ') { + event.preventDefault(); + togglePause(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + goTo(state.page + 1, false); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + goTo(state.page - 1, false); + } else if (event.key >= '1' && event.key <= '5') { + goTo(parseInt(event.key, 10) - 1, false); + } else if (event.key === 'f' || event.key === 'F') { + toggleFullscreen(); + } + }); + } + + function toggleFullscreen() { + if (document.fullscreenElement) { + if (document.exitFullscreen) document.exitFullscreen().catch(function () {}); + } else if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen().catch(function () {}); + } + } + + function fitStage() { + var scale = Math.min(window.innerWidth / 1024, window.innerHeight / 768); + stage.style.transform = 'scale(' + Math.max(scale, 0.1) + ')'; + } + + function bindCursorHide() { + document.addEventListener('mousemove', function () { + document.body.classList.remove('hide-cursor'); + window.clearTimeout(state.cursorTimer); + state.cursorTimer = window.setTimeout(function () { + document.body.classList.add('hide-cursor'); + }, 2500); + }); + } + + function initSparks() { + sparks.cpu = new Spark($('cpu-spark'), { + series: [{ key: 'cpu', color: COLORS.cyan, alpha: 0.2 }], max: 100, threshold: 80 + }); + sparks.cpu2 = new Spark($('cpu-spark-2'), { + series: [{ key: 'cpu', color: COLORS.cyan, alpha: 0.17 }], max: 100, threshold: 80 + }); + sparks.mem = new Spark($('mem-spark-large'), { + series: [{ key: 'mem', color: COLORS.blue, alpha: 0.2 }], max: 100, threshold: 85 + }); + sparks.memLarge = new Spark($('mem-spark-2'), { + series: [{ key: 'mem', color: COLORS.blue, alpha: 0.16 }], max: 100, threshold: 85 + }); + sparks.load = new Spark($('load-spark'), { + series: [{ key: 'load', color: COLORS.amber, alpha: 0.18 }], max: 16 + }); + sparks.net = new Spark($('net-spark'), { + series: [ + { key: 'tx', color: COLORS.blue, alpha: 0.08 }, + { key: 'rx', color: COLORS.cyan, alpha: 0.16 } + ] + }); + sparks.netBig = new Spark($('net-spark-big'), { + series: [ + { key: 'tx', color: COLORS.blue, alpha: 0.1 }, + { key: 'rx', color: COLORS.cyan, alpha: 0.18 } + ] + }); + sparks.io = new Spark($('io-spark'), { + series: [ + { key: 'write', color: COLORS.violet, alpha: 0.08 }, + { key: 'read', color: COLORS.cyan, alpha: 0.16 } + ] + }); + } + + function init() { + buildDots(); + initPages(); + initSparks(); + bindKeys(); + bindCursorHide(); + fitStage(); + tickClock(); + connectStream(); + window.setInterval(tickClock, 250); + window.setInterval(watchdog, 4000); + window.addEventListener('resize', fitStage); + document.addEventListener('fullscreenchange', function () { + window.setTimeout(fitStage, 80); + window.setTimeout(refitCharts, 120); + }); + window.addEventListener('load', function () { + fitStage(); + refitCharts(); + }); + + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + if (!state.paused) { + var elapsed = performance.now() - state.startedAt; + state.remaining = Math.max(1200, (state.remaining || state.rotateMs) - elapsed); + window.clearTimeout(state.rotateTimer); + state.rotateTimer = null; + var progress = $('page-progress'); + if (progress) progress.style.animationPlayState = 'paused'; + state.visibilityPaused = true; + } + } else if (state.visibilityPaused && !state.paused) { + state.visibilityPaused = false; + scheduleRotation(state.remaining); + } + }); + } + + init(); +})(); \ No newline at end of file diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..dbecbb8 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5744548 --- /dev/null +++ b/web/index.html @@ -0,0 +1,485 @@ + + + + + + + + Server Monitor + + + + + + +
+
+ + + + +
+
+ +
+
+

---

+ 连接中 + +
+
+ -- + + -- + + -- + + -- +
+
+
+ +
+
--:--:--
+
----UTC
+
+ +
+
UPTIME
+
--
+
BOOT --
+
+
+ + + +
+ + +
+
+
+
处理器CPU
+ -- THREADS +
+
+
+ +
0%UTIL
+
+
+

--

+
频率--
+
温度--
+
核心--
+
+
+
+
60S 使用率PEAK --
+ +
+
+ +
+
+
内存MEMORY
+ -- +
+
+
+ +
0%USED
+
+
+
--/ --
+
可用 AVAIL--
+
缓存 CACHE--
+
+
+
+ + + + +
+
+ 进程 + 缓存 + 空闲 +
+
+ SWAP +
+ -- +
+
+ +
+
+
节点NODE
+ HOST +
+
+
系统--
+
内核--
+
架构--
+
地址--
+
启动--
+
进程--
+
+
+ +
+
+
负载LOAD
+ -- CORES +
+
+
+ 1 MIN-- +
+
+
+ 5 MIN-- +
+
+
+ 15 MIN-- +
+
+
+
+ 运行中 -- + 线程 -- + 规范值 -- +
+
+
LOAD 1M · 60SPEAK --
+ +
+
+ +
+
+
文件系统DISK
+ -- FS +
+
+
+ +
+
+
网络NETWORK
+ LIVE +
+
+
+ ↓ 下行 RX + -- +
+
+ ↑ 上行 TX + -- +
+
+
+ +
+
+ -- 个接口 + 0 错误 +
+
+
+ + +
+
+
+
处理器详情CPU DETAIL
+ -- +
+
+
+ +
0%CPU UTIL
+
+
+

--

+
+
频率--
+
温度--
+
逻辑核心--
+
采样--
+
+
+
USER
--
+
SYSTEM
--
+
IOWAIT
--
+
STEAL
--
+
+
+
60S 使用率PEAK --
+ +
+
+ +
+
+
每核心PER CORE
+ -- CORES +
+
+
+ +
+
+
进程 TOPBY CPU
+ -- PROCS +
+ + + +
PID进程 / PROCESSCPUMEMRSS
+
+
+ + +
+
+
+
物理内存MEMORY
+ -- +
+
+
+ +
0%USED
+
+
+
--/ -- 已用
+
+ + + + +
+
+
可用 AVAILABLE--
+
缓存 CACHED--
+
缓冲 BUFFERS--
+
SWAP--
+
+
+
内存构成COMPOSITION
+
进程
--
+
缓存
--
+
缓冲
--
+
空闲
--
+
+
+
60S 使用率PEAK --
+ +
+
+ +
+
+
内存趋势60 SECONDS
+ -- +
+
+
+
当前 CURRENT--
+
峰值 PEAK--
+
均值 AVG--
+
交换 SWAP--
+
+
+ +
+
+
进程 TOPBY MEMORY
+ -- PROCS +
+ + + +
PID进程 / PROCESSMEMRSSCPU
+
+
+ + +
+
+
+
文件系统FILESYSTEMS
+ -- FS +
+ + + + + + + + + + + + + + +
挂载点 / MOUNT设备 / DEVICE类型容量已用可用使用率%
+
+
总容量--
+
已用--
+
可用--
+
--
+
--
+
+
+ +
+
+
磁盘 I/ODEVICE THROUGHPUT
+ R -- · W -- +
+
+
+ + + + + +
设备 / DEVICE读 READ写 WRITEIOPS R/W延迟 AWAIT忙碌 BUSY%
+ +
+
+
I/O 趋势 · 60S--
+ +
READWRITE
+
+
+
+
+ + +
+
+
+
网络吞吐THROUGHPUT
+ -- IFACES +
+
+
+ ↓ 下行 RX + -- + 累计 -- +
+
+ +
RXTX60S WINDOW
+
+
+ ↑ 上行 TX + -- + 累计 -- +
+
+
+ 接口 -- + 错误 -- + 丢包 -- + 主接口 -- + LIVE +
+
+ +
+
+
网卡INTERFACES
+ -- +
+ + + + + +
接口 / IFACEIP下行 RX上行 TX累计 RX/TX错误/丢包链路
+
+
+ +
+ +
+
+ + STATUS +
+
+
+ 正在连接服务器 · SERVER MONITOR INITIALIZING + +
+
+
SAMPLE ----
+
+ +
+ SERVER MONITOR v---- + ← → 切换 SPACE 暂停 F 全屏 + 等待采样… +
+
+
+ + + + \ No newline at end of file diff --git a/web/robots.txt b/web/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/web/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3190947 --- /dev/null +++ b/web/style.css @@ -0,0 +1,1677 @@ +:root { + --bg: #04070d; + --bg-2: #07101a; + --panel: #0a1420; + --panel-2: #08111b; + --line: rgba(126, 174, 224, .13); + --line-strong: rgba(126, 190, 240, .24); + --text: #e7f2ff; + --text-2: #c7d8ea; + --muted: #7e93a8; + --muted-2: #566b80; + --cyan: #52e6cf; + --cyan-2: #34c9ff; + --blue: #5aa9ff; + --violet: #b58cff; + --amber: #ffb45c; + --red: #ff6072; + --green: #62e6a8; + --mono: "JetBrains Mono", "SFMono-Regular", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace; + --sans: "Inter", "SF Pro Display", "Segoe UI", "Noto Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", "WenQuanYi Micro Hei", sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + width: 100%; + height: 100%; + margin: 0; + background: var(--bg); +} + +body { + overflow: hidden; + color: var(--text); + font-family: var(--sans); + font-size: 12px; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; +} + +button { font: inherit; color: inherit; } + +.svg-defs { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.viewport { + position: fixed; + inset: 0; + overflow: hidden; + background: + radial-gradient(900px 620px at 18% -10%, rgba(46, 160, 155, .09), transparent 62%), + radial-gradient(900px 700px at 100% 110%, rgba(51, 103, 196, .12), transparent 64%), + #03060b; +} + +.stage { + position: absolute; + left: 50%; + top: 50%; + margin-left: -512px; + margin-top: -384px; + width: 1024px; + height: 768px; + padding: 20px 22px; + display: grid; + grid-template-rows: 86px 30px 512px 42px 18px; + gap: 10px; + overflow: hidden; + border: 1px solid rgba(110, 170, 225, .16); + border-radius: 6px; + background: + radial-gradient(680px 420px at 12% 0%, rgba(31, 138, 136, .13), transparent 62%), + radial-gradient(800px 500px at 100% 100%, rgba(38, 87, 170, .14), transparent 66%), + linear-gradient(155deg, #07101a 0%, #050a12 48%, #060c15 100%); + box-shadow: 0 28px 90px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(255, 255, 255, .015); + transform-origin: center center; +} + +.stage-grid { + position: absolute; + inset: 0; + pointer-events: none; + opacity: .5; + background-image: + linear-gradient(rgba(112, 165, 220, .035) 1px, transparent 1px), + linear-gradient(90deg, rgba(112, 165, 220, .035) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: radial-gradient(ellipse at 50% 35%, black, transparent 82%); +} + +.stage-glow { + position: absolute; + width: 420px; + height: 420px; + border-radius: 50%; + pointer-events: none; + filter: blur(90px); + opacity: .18; +} +.glow-a { top: -190px; left: -100px; background: #1fb8a4; } +.glow-b { right: -150px; bottom: -230px; background: #2e63c7; opacity: .22; } + +/* ------------------------------------------------------------------ */ +/* Header */ +/* ------------------------------------------------------------------ */ +.panel { + background: + linear-gradient(180deg, rgba(18, 32, 49, .86), rgba(8, 17, 28, .92)); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035), 0 8px 26px rgba(0, 0, 0, .2); +} + +.topbar { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px 150px; + align-items: center; + gap: 22px; + padding: 0 18px; +} + +.identity { + min-width: 0; + display: flex; + align-items: center; + gap: 13px; +} + +.brand-mark { + width: 46px; + height: 46px; + flex: none; + border-radius: 12px; + display: grid; + place-items: center; + background: linear-gradient(145deg, rgba(69, 224, 203, .09), rgba(59, 124, 231, .06)); + border: 1px solid rgba(81, 221, 204, .19); + box-shadow: 0 0 24px rgba(42, 212, 190, .08), inset 0 0 14px rgba(51, 199, 183, .04); +} +.brand-mark svg { width: 40px; height: 40px; display: block; } + +.identity-copy { min-width: 0; } + +.hostline { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +h1 { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 24px; + line-height: 1.05; + font-weight: 650; + letter-spacing: -.015em; + color: #f1f8ff; + text-shadow: 0 0 30px rgba(102, 208, 255, .12); +} + +.live-pill, .demo-pill { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + height: 18px; + padding: 0 7px; + border-radius: 99px; + font-family: var(--mono); + font-size: 8px; + font-weight: 600; + letter-spacing: .1em; + text-transform: uppercase; + border: 1px solid rgba(103, 151, 196, .22); + color: var(--muted); + background: rgba(86, 120, 155, .07); +} +.live-pill i { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 5px currentColor; +} +.live-pill[data-state="live"] { + color: var(--green); + border-color: rgba(88, 226, 159, .27); + background: rgba(60, 190, 130, .07); +} +.live-pill[data-state="connecting"] { + color: var(--amber); + border-color: rgba(255, 178, 90, .25); + background: rgba(220, 153, 75, .06); +} +.live-pill[data-state="offline"] { + color: var(--red); + border-color: rgba(255, 96, 114, .3); + background: rgba(255, 96, 114, .06); +} +.live-pill[data-state="live"] i { animation: breathe 2.4s ease-in-out infinite; } +.demo-pill { + color: var(--amber); + border-color: rgba(255, 180, 92, .26); + background: rgba(255, 180, 92, .07); +} + +.host-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 7px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + font-family: var(--mono); + font-size: 9px; + letter-spacing: .025em; + color: var(--muted); +} +.host-meta > span { overflow: hidden; text-overflow: ellipsis; } +.host-meta #os-name { max-width: 210px; color: var(--text-2); } +.host-meta #kernel { max-width: 160px; } +.host-meta #arch { flex: none; } +.sep { + display: inline-block; + width: 1px; + height: 8px; + flex: none; + background: rgba(130, 172, 214, .2); +} +.host-ip { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + color: var(--cyan); +} +.ip-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 5px currentColor; +} +.host-ip b { font-weight: 500; } + +.clock-block { text-align: right; } +.clock { + font-family: var(--mono); + font-size: 42px; + line-height: .96; + font-weight: 500; + letter-spacing: -.045em; + color: #f4faff; + font-variant-numeric: tabular-nums; + text-shadow: 0 0 28px rgba(96, 197, 255, .13); +} +.clock-meta { + margin-top: 7px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 9px; + letter-spacing: .06em; + color: var(--muted); +} +#timezone { color: var(--cyan); } + +.uptime-block { + border-left: 1px solid rgba(128, 173, 216, .16); + padding-left: 18px; + min-width: 0; +} +.eyebrow { + font-family: var(--mono); + font-size: 8px; + letter-spacing: .2em; + color: var(--muted-2); +} +.uptime { + margin-top: 5px; + font-family: var(--mono); + font-size: 16px; + letter-spacing: -.01em; + color: #e9f5ff; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.boot { + margin-top: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 8px; + color: var(--muted-2); +} + +/* ------------------------------------------------------------------ */ +/* Nav */ +/* ------------------------------------------------------------------ */ +.nav { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + padding: 0 4px; +} +.nav-current { display: flex; align-items: center; gap: 10px; min-width: 0; } +.page-index { + font-family: var(--mono); + font-size: 13px; + letter-spacing: .04em; + color: var(--cyan); + padding-right: 10px; + border-right: 1px solid rgba(119, 169, 215, .2); +} +.nav-titles { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.nav-titles strong { + font-size: 13px; + font-weight: 600; + letter-spacing: .02em; + color: #e6f1fd; + white-space: nowrap; +} +.nav-titles span { + font-family: var(--mono); + font-size: 8px; + letter-spacing: .15em; + color: var(--muted-2); + white-space: nowrap; +} + +.dots { display: flex; align-items: center; gap: 7px; justify-self: center; } +.dot { + position: relative; + width: 26px; + height: 18px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-2); + cursor: pointer; + font-family: var(--mono); + font-size: 8px; + letter-spacing: .05em; + transition: color .25s; +} +.dot::after { + content: ""; + position: absolute; + left: 50%; + bottom: 0; + width: 12px; + height: 2px; + border-radius: 2px; + background: currentColor; + opacity: .22; + transform: translateX(-50%); + transition: width .25s, background .25s, opacity .25s; +} +.dot:hover { color: var(--text-2); } +.dot.is-active { color: var(--cyan); } +.dot.is-active::after { + width: 20px; + opacity: 1; + background: var(--cyan); + box-shadow: 0 0 8px rgba(82, 230, 207, .6); +} + +.nav-state { + justify-self: end; + display: flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 8px; + letter-spacing: .06em; + color: var(--muted); + white-space: nowrap; +} +.state-led { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 6px rgba(98, 230, 168, .8); +} +.nav.is-paused .state-led { background: var(--amber); box-shadow: 0 0 6px rgba(255, 180, 92, .7); } +.key-hint { + margin-left: 3px; + padding: 2px 5px; + border: 1px solid rgba(119, 163, 204, .2); + border-radius: 3px; + color: var(--muted-2); + font-size: 7px; + letter-spacing: .08em; +} +.progress { + position: absolute; + left: 4px; + right: 4px; + bottom: 1px; + height: 1px; + overflow: hidden; + background: rgba(110, 155, 200, .1); +} +.progress i { + display: block; + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(82, 230, 207, .9), rgba(90, 169, 255, .75)); + box-shadow: 0 0 8px rgba(82, 230, 207, .55); + transform: scaleX(0); + transform-origin: left center; + will-change: transform; +} + +/* ------------------------------------------------------------------ */ +/* Pages & cards */ +/* ------------------------------------------------------------------ */ +.pages { position: relative; min-height: 0; } + +.page { + position: absolute; + inset: 0; + display: grid; + gap: 10px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateX(30px) scale(.996); + transition: opacity .42s ease, transform .52s cubic-bezier(.22, .68, .24, 1), visibility .52s; +} +.page.is-active { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateX(0) scale(1); + z-index: 2; +} +.page.is-prev { + opacity: 0; + visibility: visible; + transform: translateX(-30px) scale(.996); + z-index: 1; +} + +.card { + position: relative; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: + linear-gradient(155deg, rgba(17, 31, 48, .84) 0%, rgba(8, 17, 28, .9) 56%, rgba(7, 15, 25, .92) 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .028), + 0 7px 22px rgba(0, 0, 0, .16); +} +.card::before { + content: ""; + position: absolute; + top: 0; + left: 16px; + right: 16px; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(128, 205, 255, .2), transparent); + opacity: .65; + pointer-events: none; +} +.card-head { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 20px; + margin-bottom: 8px; +} +.card-title { display: flex; align-items: baseline; min-width: 0; } +.card-title .cn { + font-size: 12px; + font-weight: 600; + letter-spacing: .02em; + color: #dceafa; + white-space: nowrap; +} +.card-title .en { + margin-left: 7px; + font-family: var(--mono); + font-size: 8px; + letter-spacing: .15em; + color: var(--muted-2); + white-space: nowrap; +} +.card-badge { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + height: 17px; + padding: 0 6px; + border: 1px solid rgba(111, 161, 207, .16); + border-radius: 3px; + background: rgba(72, 116, 158, .055); + font-family: var(--mono); + font-size: 7.5px; + font-weight: 500; + letter-spacing: .075em; + color: var(--muted); + white-space: nowrap; +} +.live-badge { color: var(--green); border-color: rgba(98, 230, 168, .2); } +.live-badge i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; box-shadow: 0 0 4px currentColor; } + +/* ------------------------------------------------------------------ */ +/* Rings */ +/* ------------------------------------------------------------------ */ +.ring { + --pct: 0; + position: relative; + flex: none; + width: 104px; + height: 104px; + color: var(--cyan); +} +.ring svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); } +.ring-track { + fill: none; + stroke: rgba(112, 158, 203, .13); + stroke-width: 6.5; +} +.ring-value { + fill: none; + stroke: currentColor; + stroke-width: 6.5; + stroke-linecap: round; + stroke-dasharray: 100; + stroke-dashoffset: calc(100 - var(--pct)); + transition: stroke-dashoffset .7s cubic-bezier(.3, .8, .3, 1), stroke .3s ease; + filter: drop-shadow(0 0 4px currentColor); +} +.ring[data-tone="warn"] { color: var(--amber); } +.ring[data-tone="crit"] { color: var(--red); } +.ring[data-tone="ok"] { color: var(--cyan); } +.ring-mem[data-tone="ok"] { color: var(--blue); } +.ring-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + pointer-events: none; +} +.ring-center b { + font-family: var(--mono); + font-size: 26px; + line-height: 1; + font-weight: 600; + letter-spacing: -.055em; + color: #f2faff; + font-variant-numeric: tabular-nums; +} +.ring-center b span { font-size: 11px; margin-left: 1px; color: var(--muted); letter-spacing: 0; } +.ring-center small { + font-family: var(--mono); + font-size: 7px; + letter-spacing: .18em; + color: var(--muted-2); +} +.ring-hero { width: 166px; height: 166px; } +.ring-hero .ring-value, .ring-hero .ring-track { stroke-width: 4.6; } +.ring-hero .ring-center b { font-size: 38px; } +.ring-hero .ring-center b span { font-size: 14px; } +.ring-hero .ring-center small { font-size: 8px; margin-top: 4px; } + +/* ------------------------------------------------------------------ */ +/* Sparklines */ +/* ------------------------------------------------------------------ */ +.spark-wrap { + position: relative; + margin-top: auto; + min-height: 0; +} +.spark-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 3px; + font-family: var(--mono); + font-size: 7.5px; + letter-spacing: .08em; + color: var(--muted-2); +} +.spark-peak { color: var(--muted); } +.spark { display: block; width: 100%; height: 54px; overflow: visible; } +.spark-wrap-load .spark { height: 58px; } +.spark-wrap-net .spark { height: 68px; } +.spark-wrap-hero .spark { height: 66px; } +.spark-wrap-mem .spark { height: 62px; } + +/* ------------------------------------------------------------------ */ +/* Overview */ +/* ------------------------------------------------------------------ */ +.page-overview { + grid-template-columns: 1.28fr 1fr .78fr; + grid-template-rows: 246px 256px; +} +.card-cpu { grid-column: 1; grid-row: 1; } +.card-mem { grid-column: 2; grid-row: 1; } +.card-sys { grid-column: 3; grid-row: 1; } +.card-load { grid-column: 1; grid-row: 2; } +.card-disk { grid-column: 2; grid-row: 2; } +.card-net { grid-column: 3; grid-row: 2; } + +.cpu-main { + flex: none; + display: flex; + align-items: center; + gap: 14px; + height: 106px; +} +.cpu-facts { flex: 1; min-width: 0; } +.cpu-model { + margin: 0 0 8px; + font-size: 9px; + line-height: 1.35; + color: var(--text-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 24px; +} +.fact-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + height: 18px; + border-top: 1px solid rgba(112, 159, 204, .075); + font-size: 8.5px; + color: var(--muted); +} +.fact-row b { + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + color: var(--text-2); + font-variant-numeric: tabular-nums; +} +.card-cpu .spark-wrap { margin-top: auto; } + +.mem-main { + flex: none; + display: flex; + align-items: center; + gap: 12px; + height: 98px; +} +.ring-mem { width: 94px; height: 94px; } +.ring-mem .ring-center b { font-size: 23px; } +.mem-facts { flex: 1; min-width: 0; } +.mem-total { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 5px; + white-space: nowrap; +} +.mem-total b { + font-family: var(--mono); + font-size: 20px; + line-height: 1; + font-weight: 600; + letter-spacing: -.04em; + color: #f1f9ff; +} +.mem-total span { font-family: var(--mono); font-size: 9px; color: var(--muted); } +.mem-total em { font-style: normal; color: var(--text-2); } +.kv-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 7px; + height: 18px; + border-top: 1px solid rgba(112, 159, 204, .075); + font-size: 8px; + color: var(--muted); + white-space: nowrap; +} +.kv-line > span { overflow: hidden; text-overflow: ellipsis; letter-spacing: .02em; } +.kv-line b { + font-family: var(--mono); + font-size: 9.5px; + font-weight: 500; + color: var(--text-2); +} + +.composition { + flex: none; + display: flex; + width: 100%; + height: 6px; + margin-top: auto; + border-radius: 3px; + overflow: hidden; + background: rgba(100, 145, 190, .1); +} +.composition .seg { display: block; height: 100%; transition: width .6s ease; } +.seg-apps { background: linear-gradient(90deg, #3f8cff, #5aa9ff); } +.seg-cache { background: linear-gradient(90deg, #2b6fb5, #3e8ecf); opacity: .8; } +.seg-buffers { background: #305a7a; opacity: .75; } +.seg-free { background: rgba(116, 158, 198, .15); } +.composition-legend { + flex: none; + display: flex; + gap: 12px; + margin-top: 6px; + font-size: 7.5px; + color: var(--muted-2); + letter-spacing: .03em; +} +.composition-legend span { display: inline-flex; align-items: center; gap: 4px; } +.legend-dot { width: 4px; height: 4px; border-radius: 1px; display: inline-block; } +.legend-dot.apps { background: var(--blue); } +.legend-dot.cache { background: #3e8ecf; } +.legend-dot.free { background: #456176; } +.swap-line { + flex: none; + display: flex; + align-items: center; + gap: 7px; + margin-top: 7px; + font-family: var(--mono); + font-size: 7.5px; + color: var(--muted-2); +} +.swap-label { letter-spacing: .12em; } +.swap-line .mini-bar { flex: 1; } +.swap-line b { font-weight: 500; color: var(--text-2); font-size: 8px; } + +.mini-bar { + position: relative; + height: 3px; + border-radius: 3px; + overflow: hidden; + background: rgba(106, 151, 196, .12); +} +.mini-bar > i { + display: block; + width: var(--w, 0%); + height: 100%; + border-radius: inherit; + background: var(--bar-color, var(--blue)); + box-shadow: 0 0 6px var(--bar-glow, rgba(90, 169, 255, .35)); + transition: width .55s cubic-bezier(.3, .8, .3, 1), background .3s; +} + +.sys-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.sys-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-height: 25px; + border-top: 1px solid rgba(112, 159, 204, .08); + font-size: 8.5px; + color: var(--muted); +} +.sys-row:first-child { border-top: 0; } +.sys-row span { flex: none; letter-spacing: .03em; } +.sys-row b { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 9px; + font-weight: 500; + color: var(--text-2); + text-align: right; +} + +.load-stats { + flex: none; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + padding: 2px 0 8px; +} +.load-stat > span { + font-family: var(--mono); + font-size: 7px; + letter-spacing: .12em; + color: var(--muted-2); +} +.load-stat b { + display: block; + margin: 3px 0 6px; + font-family: var(--mono); + font-size: 24px; + line-height: 1; + font-weight: 600; + letter-spacing: -.055em; + color: #edf7ff; + font-variant-numeric: tabular-nums; +} +.load-stat .mini-bar { --bar-color: var(--amber); --bar-glow: rgba(255, 180, 92, .4); } +.load-foot { + flex: none; + display: flex; + justify-content: space-between; + gap: 10px; + padding-top: 7px; + border-top: 1px solid rgba(112, 159, 204, .085); + font-size: 8px; + color: var(--muted-2); +} +.load-foot b { font-family: var(--mono); font-weight: 500; color: var(--text-2); } +.spark-wrap-load { margin-top: auto; } + +.disk-list { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: flex-start; gap: 7px; } +.disk-row { min-width: 0; } +.disk-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.disk-mount { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 10px; + color: var(--text-2); +} +.disk-mount span { font-family: var(--sans); font-size: 8px; color: var(--muted-2); margin-left: 5px; } +.disk-pct { + flex: none; + font-family: var(--mono); + font-size: 10px; + color: var(--text); +} +.disk-bar { height: 4px; border-radius: 3px; background: rgba(106, 151, 196, .12); overflow: hidden; } +.disk-bar i { + display: block; + height: 100%; + width: var(--w, 0%); + border-radius: inherit; + background: var(--bar-color, var(--violet)); + box-shadow: 0 0 7px var(--bar-glow, rgba(181, 140, 255, .32)); + transition: width .55s ease; +} +.disk-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 3px; + font-family: var(--mono); + font-size: 7px; + color: var(--muted-2); +} + +.net-rates { + flex: none; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding-bottom: 3px; +} +.net-rate { min-width: 0; } +.net-rate > span { + display: block; + margin-bottom: 4px; + font-size: 7.5px; + letter-spacing: .06em; + color: var(--muted-2); + white-space: nowrap; +} +.net-rate b { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 17px; + line-height: 1; + font-weight: 600; + letter-spacing: -.045em; + color: #eef8ff; + font-variant-numeric: tabular-nums; +} +.net-rate.rx b { color: #78f0d9; } +.net-rate.tx b { color: #83baff; } +.net-foot { + flex: none; + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 5px; + font-family: var(--mono); + font-size: 7.5px; + color: var(--muted-2); +} +.net-foot b { color: var(--text-2); font-weight: 500; } +.spark-wrap-net { margin-top: auto; } + +/* ------------------------------------------------------------------ */ +/* CPU page */ +/* ------------------------------------------------------------------ */ +.page-cpu { + grid-template-columns: 300px minmax(0, 1fr); + grid-template-rows: 246px 256px; +} +.cpu-hero { grid-column: 1; grid-row: 1 / 3; } +.core-card { grid-column: 2; grid-row: 1; } +.proc-card { grid-column: 2; grid-row: 2; } + +.hero-ring { display: flex; justify-content: center; padding: 3px 0 7px; } +.hero-model { + margin: 0; + min-height: 25px; + text-align: center; + font-size: 8.5px; + line-height: 1.4; + color: var(--text-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.hero-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; + margin-top: 10px; + padding: 9px 0; + border-top: 1px solid rgba(112, 159, 204, .09); + border-bottom: 1px solid rgba(112, 159, 204, .09); +} +.hero-facts span { + display: block; + margin-bottom: 2px; + font-size: 7.5px; + color: var(--muted-2); +} +.hero-facts b { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + color: var(--text-2); +} +.breakdown { margin-top: 10px; display: flex; flex-direction: column; gap: 7px; } +.breakdown-row { + display: grid; + grid-template-columns: 45px 1fr 32px; + align-items: center; + gap: 7px; + font-family: var(--mono); + font-size: 7px; + color: var(--muted-2); +} +.breakdown-row > b { text-align: right; font-size: 8px; font-weight: 500; color: var(--text-2); } +.breakdown-row .mini-bar { --bar-color: var(--cyan); --bar-glow: rgba(82, 230, 207, .3); } +.breakdown-row:nth-child(2) .mini-bar { --bar-color: var(--blue); --bar-glow: rgba(90, 169, 255, .3); } +.breakdown-row:nth-child(3) .mini-bar { --bar-color: var(--amber); --bar-glow: rgba(255, 180, 92, .3); } +.breakdown-row:nth-child(4) .mini-bar { --bar-color: var(--violet); --bar-glow: rgba(181, 140, 255, .3); } +.spark-wrap-hero { margin-top: auto; } + +.core-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 7px; +} +.core { + position: relative; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 7px 7px; + border: 1px solid rgba(111, 159, 204, .1); + border-radius: 6px; + background: linear-gradient(180deg, rgba(23, 42, 61, .45), rgba(9, 19, 30, .48)); +} +.core-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 3px; + font-family: var(--mono); + font-size: 7px; + color: var(--muted-2); +} +.core-head b { font-size: 8px; font-weight: 500; color: var(--text-2); } +.core-track { + position: relative; + flex: 1; + min-height: 24px; + width: 100%; + overflow: hidden; + border-radius: 3px; + background: + linear-gradient(180deg, rgba(104, 149, 194, .04), rgba(104, 149, 194, .1)); +} +.core-track i { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: var(--v, 0%); + min-height: 2px; + border-radius: 2px; + background: linear-gradient(180deg, var(--cyan), rgba(49, 174, 187, .5)); + box-shadow: 0 0 9px rgba(82, 230, 207, .25); + transition: height .55s cubic-bezier(.3, .8, .3, 1), background .3s; +} +.core[data-tone="warn"] .core-track i { background: linear-gradient(180deg, var(--amber), rgba(179, 118, 46, .55)); box-shadow: 0 0 9px rgba(255, 180, 92, .2); } +.core[data-tone="crit"] .core-track i { background: linear-gradient(180deg, var(--red), rgba(179, 70, 80, .55)); box-shadow: 0 0 9px rgba(255, 96, 114, .24); } + +.data-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 9.5px; +} +.data-table th { + padding: 0 6px 6px; + border-bottom: 1px solid rgba(117, 162, 205, .14); + font-family: var(--mono); + font-size: 7px; + font-weight: 500; + letter-spacing: .09em; + text-align: left; + color: var(--muted-2); + white-space: nowrap; +} +.data-table td { + height: 24px; + padding: 2px 6px; + border-bottom: 1px solid rgba(112, 159, 204, .065); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); + font-variant-numeric: tabular-nums; +} +.data-table tr:last-child td { border-bottom: 0; } +.data-table .num { text-align: right; font-family: var(--mono); } +.data-table th.num { text-align: right; } +.col-pid { width: 48px; } +.proc-table { flex: 1; min-height: 0; } +.proc-name { display: flex; align-items: baseline; gap: 7px; min-width: 0; } +.proc-name b { + flex: none; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--mono); + font-size: 9.5px; + font-weight: 500; + color: #e4f1ff; +} +.proc-name span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--mono); + font-size: 7.5px; + color: var(--muted-2); +} +.proc-pid { color: var(--muted-2); font-size: 8px; } +.proc-cpu { color: var(--cyan); } +.proc-cpu.hot { color: var(--amber); } +.proc-cpu.critical { color: var(--red); } +.proc-mem { color: var(--blue); } +.proc-rss { color: var(--muted); } +.empty-row { color: var(--muted-2) !important; text-align: center !important; font-family: var(--mono); font-size: 8px !important; } + +/* ------------------------------------------------------------------ */ +/* Memory page */ +/* ------------------------------------------------------------------ */ +.page-memory { + grid-template-columns: 380px minmax(0, 1fr); + grid-template-rows: 246px 256px; +} +.mem-hero { grid-column: 1; grid-row: 1 / 3; } +.mem-trend { grid-column: 2; grid-row: 1; } +.mem-proc-card { grid-column: 2; grid-row: 2; } + +.mem-total-line { + display: flex; + align-items: baseline; + justify-content: center; + gap: 4px; + margin-top: 1px; + white-space: nowrap; +} +.mem-total-line b { + font-family: var(--mono); + font-size: 22px; + line-height: 1; + font-weight: 600; + letter-spacing: -.045em; + color: #f0f9ff; +} +.mem-total-line span { font-size: 9px; color: var(--muted); } +.mem-total-line em { font-family: var(--mono); font-style: normal; color: var(--text-2); } +.composition-large { height: 8px; margin-top: 14px; } +.mem-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 14px; + margin-top: 10px; + padding: 9px 0; + border-top: 1px solid rgba(112, 159, 204, .09); + border-bottom: 1px solid rgba(112, 159, 204, .09); +} +.mem-stats span { + display: block; + margin-bottom: 3px; + font-size: 7.5px; + letter-spacing: .015em; + color: var(--muted-2); +} +.mem-stats b { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + color: var(--text-2); +} +.mem-hero .ring-hero { width: 158px; height: 158px; } +.mem-breakdown { + flex: none; + margin-top: 8px; + padding-top: 7px; + border-top: 1px solid rgba(112, 159, 204, .09); +} +.mem-bd-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 4px; + font-family: var(--mono); + font-size: 6.8px; + letter-spacing: .07em; + color: var(--muted-2); +} +.mem-bd-head span:last-child { letter-spacing: .11em; color: rgba(126, 147, 168, .65); } +.mem-bd-row { + display: grid; + grid-template-columns: 62px minmax(0, 1fr) 36px; + align-items: center; + gap: 8px; + height: 17px; + font-size: 7.5px; + color: var(--muted); +} +.mem-bd-row .mini-bar { height: 3px; } +.mem-bd-row b { + font-family: var(--mono); + font-size: 8px; + font-weight: 500; + text-align: right; + color: var(--text-2); +} +.mem-key { + display: inline-block; + width: 4px; + height: 4px; + margin-right: 4px; + border-radius: 1px; + vertical-align: 1px; + background: var(--blue); +} +.mem-key.cache { background: #3e8ecf; } +.mem-key.buffers { background: #305a7a; } +.mem-key.free { background: #456176; } +.spark-wrap-mem { margin-top: auto; } + +.trend-chart { flex: 1; min-height: 0; display: flex; align-items: stretch; } +.trend-chart .spark { height: 100%; min-height: 100px; } +.trend-stats { + flex: none; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 7px; + padding-top: 8px; + border-top: 1px solid rgba(112, 159, 204, .08); +} +.trend-stats span { display: block; margin-bottom: 2px; font-size: 7px; color: var(--muted-2); letter-spacing: .05em; } +.trend-stats b { font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--text-2); } + +/* ------------------------------------------------------------------ */ +/* Storage page */ +/* ------------------------------------------------------------------ */ +.page-storage { + grid-template-columns: 1fr; + grid-template-rows: 256px 246px; +} +.fs-card { grid-row: 1; } +.io-card { grid-row: 2; } +.fs-table, .io-table { flex: none; } +.iface-table { flex: 1; min-height: 0; } +.fs-table th, .fs-table td { padding-left: 7px; padding-right: 7px; } +.fs-table th:nth-child(1) { width: 20%; } +.fs-table th:nth-child(2) { width: 21%; } +.fs-table th:nth-child(3) { width: 8%; } +.fs-table th:nth-child(4) { width: 10%; } +.fs-table th:nth-child(5) { width: 10%; } +.fs-table th:nth-child(6) { width: 10%; } +.fs-table th:nth-child(7) { width: 15%; } +.fs-table th:nth-child(8) { width: 6%; } +.fs-table td { height: 28px; } +.fs-mount { color: #e5f1ff; font-family: var(--mono); font-size: 10px; } +.fs-device { color: var(--muted); font-family: var(--mono); font-size: 8px; } +.fs-type { color: var(--muted-2); font-family: var(--mono); font-size: 8px; } +.fs-num { color: var(--text-2); font-family: var(--mono); font-size: 8.5px; } +.fs-usage { display: flex; align-items: center; gap: 7px; } +.fs-usage .mini-bar { flex: 1; height: 4px; } +.fs-pct { font-family: var(--mono); font-size: 9px; } +.fs-ro { margin-left: 4px; color: var(--amber); font-size: 7px; } + +.io-table th:nth-child(1) { width: 22%; } +.io-table th:nth-child(2) { width: 16%; } +.io-table th:nth-child(3) { width: 16%; } +.io-table th:nth-child(4) { width: 14%; } +.io-table th:nth-child(5) { width: 14%; } +.io-table th:nth-child(6) { width: 14%; } +.io-table th:nth-child(7) { width: 5%; } +.io-table td { height: 28px; } +.io-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 18px; +} +.io-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.io-footer { + margin-top: auto; + display: flex; + align-items: center; + gap: 18px; + padding-top: 7px; + border-top: 1px solid rgba(112, 159, 204, .08); + font-size: 7.5px; + color: var(--muted-2); + white-space: nowrap; +} +.io-footer b { font-family: var(--mono); font-weight: 500; color: var(--text-2); margin-left: 3px; } +.io-side { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding-left: 16px; + border-left: 1px solid rgba(112, 159, 204, .09); +} +.io-side-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + font-family: var(--mono); + font-size: 7.5px; + letter-spacing: .07em; + color: var(--muted-2); +} +.io-side-head b { + font-size: 12px; + font-weight: 500; + letter-spacing: -.02em; + color: #e9f5ff; +} +.io-side .spark { flex: 1; width: 100%; height: auto; min-height: 56px; } +.io-legend { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; + font-family: var(--mono); + font-size: 7px; + letter-spacing: .07em; + color: var(--muted-2); +} +.io-legend span::before { + content: ""; + display: inline-block; + width: 8px; + height: 2px; + margin-right: 3px; + vertical-align: middle; + background: currentColor; +} +.io-legend .read { color: var(--cyan); } +.io-legend .write { color: var(--violet); } + +.fs-summary { + flex: none; + margin-top: auto; + display: grid; + grid-template-columns: 88px 88px 88px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding-top: 8px; + border-top: 1px solid rgba(112, 159, 204, .085); +} +.fs-sum-item span { + display: block; + margin-bottom: 2px; + font-size: 7px; + letter-spacing: .04em; + color: var(--muted-2); +} +.fs-sum-item b { + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + color: var(--text-2); + white-space: nowrap; +} +.fs-sum-bar { display: flex; align-items: center; gap: 8px; min-width: 0; } +.fs-sum-bar .mini-bar { flex: 1; height: 4px; --bar-color: var(--violet); --bar-glow: rgba(181, 140, 255, .35); } +.fs-sum-bar b { + flex: none; + min-width: 42px; + text-align: right; + font-family: var(--mono); + font-size: 9px; + font-weight: 500; + color: var(--text-2); +} +.fs-sum-more { + justify-self: end; + font-size: 7.5px; + color: var(--muted-2); + white-space: nowrap; +} +.io-device { color: #e5f1ff; font-family: var(--mono); font-size: 10px; } +.io-rate { font-family: var(--mono); font-size: 9px; } +.io-rate.read { color: #78f0d9; } +.io-rate.write { color: #83baff; } +.io-busy { display: flex; align-items: center; gap: 7px; } +.io-busy .mini-bar { flex: 1; height: 4px; } +.bar-col { width: 15%; } + +/* ------------------------------------------------------------------ */ +/* Network page */ +/* ------------------------------------------------------------------ */ +.page-network { + grid-template-columns: 1fr; + grid-template-rows: 246px 256px; +} +.net-hero-card { grid-row: 1; } +.iface-card { grid-row: 2; } +.net-hero-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 195px minmax(0, 1fr) 195px; + align-items: center; + gap: 22px; + padding: 0 4px; +} +.net-big { min-width: 0; } +.net-big.tx { text-align: right; } +.net-direction { + display: block; + margin-bottom: 7px; + font-family: var(--mono); + font-size: 8px; + letter-spacing: .12em; + color: var(--muted-2); +} +.net-big b { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 33px; + line-height: 1; + font-weight: 600; + letter-spacing: -.06em; + font-variant-numeric: tabular-nums; +} +.net-big.rx b { color: #78f0d9; text-shadow: 0 0 24px rgba(82, 230, 207, .13); } +.net-big.tx b { color: #8ec1ff; text-shadow: 0 0 24px rgba(90, 169, 255, .13); } +.net-big small { + display: block; + margin-top: 8px; + font-family: var(--mono); + font-size: 8px; + color: var(--muted-2); +} +.net-big-chart { position: relative; height: 135px; min-width: 0; } +.net-big-chart .spark { height: 118px; } +.chart-legend { + display: flex; + align-items: center; + gap: 9px; + font-family: var(--mono); + font-size: 7px; + letter-spacing: .07em; + color: var(--muted-2); +} +.chart-legend .rx { color: var(--cyan); } +.chart-legend .tx { color: var(--blue); } +.chart-legend .window { margin-left: auto; } +.chart-legend span::before { + content: ""; + display: inline-block; + width: 8px; + height: 2px; + margin-right: 3px; + vertical-align: middle; + background: currentColor; +} +.chart-legend .window::before { display: none; } +.net-summary { + flex: none; + display: flex; + align-items: center; + gap: 20px; + padding-top: 8px; + border-top: 1px solid rgba(112, 159, 204, .08); + font-size: 8px; + color: var(--muted-2); +} +.net-summary b { font-family: var(--mono); font-weight: 500; color: var(--text-2); margin-left: 3px; } +.net-sum-live { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--mono); + font-size: 7.5px; + letter-spacing: .1em; + color: var(--green); +} +.net-sum-live i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; box-shadow: 0 0 5px currentColor; } + +.iface-table th:nth-child(1) { width: 18%; } +.iface-table th:nth-child(2) { width: 14%; } +.iface-table th:nth-child(3) { width: 13%; } +.iface-table th:nth-child(4) { width: 13%; } +.iface-table th:nth-child(5) { width: 20%; } +.iface-table th:nth-child(6) { width: 12%; } +.iface-table th:nth-child(7) { width: 10%; } +.iface-table td { height: 28px; } +.iface-name { display: flex; align-items: center; gap: 7px; min-width: 0; } +.iface-name i { + width: 4px; + height: 4px; + flex: none; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 5px rgba(98, 230, 168, .7); +} +.iface-name i.virtual { background: var(--violet); box-shadow: 0 0 5px rgba(181, 140, 255, .5); } +.iface-name b { font-family: var(--mono); font-size: 10px; font-weight: 500; color: #e5f1ff; overflow: hidden; text-overflow: ellipsis; } +.iface-name span { + flex: none; + padding: 1px 4px; + border: 1px solid rgba(98, 230, 168, .18); + border-radius: 3px; + font-family: var(--mono); + font-size: 6px; + letter-spacing: .06em; + color: var(--green); +} +.iface-ip { color: var(--muted); font-family: var(--mono); font-size: 8.5px; } +.iface-rate { font-family: var(--mono); font-size: 9px; } +.iface-rate.rx { color: #78f0d9; } +.iface-rate.tx { color: #83baff; } +.iface-total { color: var(--muted); font-family: var(--mono); font-size: 8px; } +.iface-errors { color: var(--muted); font-family: var(--mono); font-size: 8px; } +.iface-errors.bad { color: var(--red); } +.iface-link { color: var(--muted); font-family: var(--mono); font-size: 8px; } +.iface-link b { color: var(--text-2); font-weight: 500; } + +/* ------------------------------------------------------------------ */ +/* Ticker & footer */ +/* ------------------------------------------------------------------ */ +.ticker { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-width: 0; + padding: 0 14px; + overflow: hidden; + border: 1px solid rgba(255, 180, 92, .13); + border-radius: 10px; + background: + linear-gradient(90deg, rgba(34, 28, 21, .4), rgba(11, 19, 28, .9) 18%, rgba(9, 17, 27, .92) 82%, rgba(24, 27, 31, .5)); + box-shadow: inset 0 1px 0 rgba(255, 205, 145, .035); +} +.ticker-label { + display: flex; + align-items: center; + gap: 6px; + padding-right: 13px; + border-right: 1px solid rgba(255, 180, 92, .16); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .16em; + color: var(--amber); +} +.ticker-label svg { width: 13px; height: 13px; opacity: .9; } +.ticker-window { + min-width: 0; + overflow: hidden; + mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 24px), transparent); +} +.ticker-track { + display: inline-flex; + width: max-content; + white-space: nowrap; + will-change: transform; + animation: ticker-marquee 72s linear infinite; +} +.ticker-copy { + display: inline-block; + padding-right: 80px; + font-family: var(--mono); + font-size: 9px; + letter-spacing: .02em; + color: #b9cce0; +} +.ticker-side { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 7.5px; + color: var(--muted-2); + white-space: nowrap; +} +.ticker-side .sep { height: 7px; } +@keyframes ticker-marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +@keyframes breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: .35; } +} + +.footline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-width: 0; + padding: 0 4px; + font-family: var(--mono); + font-size: 7.5px; + letter-spacing: .04em; + color: var(--muted-2); + white-space: nowrap; +} +.foot-left, .foot-hint, .foot-right { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.foot-left b { color: var(--muted); font-weight: 500; } +.foot-led { + width: 4px; + height: 4px; + flex: none; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 5px rgba(98, 230, 168, .7); +} +.foot-led.offline { background: var(--red); box-shadow: 0 0 5px rgba(255, 96, 114, .7); } +.foot-led.paused { background: var(--amber); box-shadow: 0 0 5px rgba(255, 180, 92, .7); } +.foot-hint { justify-content: center; opacity: .75; } +.foot-right { justify-content: flex-end; } +.footline .sep { height: 7px; } + +.hide-cursor { cursor: none; } + +/* A hair of texture without hurting readability. */ +.stage::after { + content: ""; + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, .018), transparent 12%), + repeating-linear-gradient(0deg, rgba(255, 255, 255, .012) 0, rgba(255, 255, 255, .012) 1px, transparent 1px, transparent 3px); + opacity: .4; + mix-blend-mode: soft-light; +} + + +/* Dynamic state helpers */ +.tone-warn { color: var(--amber) !important; } +.tone-crit { color: var(--red) !important; } +.empty-note { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 80px; + color: var(--muted-2); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .06em; +} +.iface-name span.virtual-badge { + border-color: rgba(181, 140, 255, .2); + color: var(--violet); +} +.iface-name span.primary-badge { + border-color: rgba(98, 230, 168, .22); + color: var(--green); +} +@keyframes page-progress-fill { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +@media (prefers-reduced-motion: reduce) { + .page, .ring-value, .mini-bar > i, .core-track i, .disk-bar i, .composition .seg { + transition: none !important; + } + .ticker-track { animation: none; } + .live-pill[data-state="live"] i { animation: none; } +} \ No newline at end of file