diff --git a/alerter.py b/alerter.py index 06abf43..f584845 100644 --- a/alerter.py +++ b/alerter.py @@ -4,6 +4,7 @@ import logging import time import monitor +import templates log = logging.getLogger("alerter") @@ -60,8 +61,7 @@ class Alerter: self._consecutive = 0 if self._alerting: self._alerting = False - msg = (f"✅ [恢复] 服务器 CPU 已回落到 {cpu_percent:.1f}%," - f"负载恢复正常。") + msg = templates.render("recovered", cpu=f"{cpu_percent:.1f}") log.info(msg) await self.ob.broadcast(msg, self.groups, self.friends) @@ -69,16 +69,18 @@ class Alerter: top = monitor.get_top_processes(self.top_n) cpu = monitor.get_cpu() load = cpu["load"] - lines = [ - f"⚠️ [高负载告警] CPU 持续超过 {self.threshold:.0f}%,当前 {cpu_percent:.1f}%", - f"负载: {load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f} 核心: {cpu['cores']}", - "", - f"TOP{self.top_n} 高负载进程:", - ] - for i, p in enumerate(top, 1): - lines.append( - f"{i}. {p['name']} (PID {p['pid']}) CPU {p['cpu']}% 内存 {p['mem_mb']}MB" - ) - msg = "\n".join(lines) + top_list = "\n".join( + f"{i}. {p['name']} (PID {p['pid']}) CPU {p['cpu']}% 内存 {p['mem_mb']}MB" + for i, p in enumerate(top, 1) + ) + msg = templates.render( + "alert", + threshold=f"{self.threshold:.0f}", + cpu=f"{cpu_percent:.1f}", + cores=cpu["cores"], + load=f"{load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f}", + top_n=self.top_n, + top_list=top_list or "无数据", + ) log.warning("触发高负载告警: %.1f%%", cpu_percent) await self.ob.broadcast(msg, self.groups, self.friends) diff --git a/bot.py b/bot.py index c687aeb..01f8c79 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,7 @@ import logging import sys import monitor +import templates from alerter import Alerter from onebot import OneBotServer @@ -29,7 +30,7 @@ def load_config() -> dict: sys.exit(1) -# ---------- 消息格式化 ---------- +# ---------- 消息格式化(模板见 templates/) ---------- def _fmt_uptime(seconds: float) -> str: d, r = divmod(int(seconds), 86400) @@ -38,76 +39,83 @@ def _fmt_uptime(seconds: float) -> str: return f"{d}天{h}小时{m}分" if d else f"{h}小时{m}分" +def _power_str(p: dict) -> str: + if p["total_watts"] is None: + return "不支持(需要电池或Intel RAPL)" + src = {"battery": "电池", "rapl": "RAPL"}.get(p["source"], p["source"]) + extra = f" (电池 {p['battery']}W)" if p["battery"] is not None and p["source"] == "rapl" else "" + return f"{p['total_watts']}W [{src}]{extra}" + + +def _hottest(temps: dict): + flat = [t for entries in temps.values() for t in entries] + return max(flat, key=lambda t: t["current"]) if flat else None + + def fmt_status(s: dict) -> str: - p, c, m = s["power"], s["cpu"], s["memory"] - lines = ["📊 服务器状态"] - power_str = "不支持" - if p["total_watts"] is not None: - src = {"battery": "🔋电池", "rapl": "RAPL"}.get(p["source"], p["source"]) - extra = f" (电池 {p['battery']}W)" if p["battery"] is not None and p["source"] == "rapl" else "" - power_str = f"{p['total_watts']}W [{src}]{extra}" - lines.append(f"⚡ 功耗: {power_str}") - load = c["load"] - total_pct = sum(c["per_core"]) - lines.append(f"🖥 CPU: {total_pct:.1f}% / {c['cores'] * 100}% " - f"(单核均值 {c['percent']}%) 核心 {c['cores']} " - f"负载 {load[0]:.2f}/{load[1]:.2f}/{load[2]:.2f}") - lines.append(f"💾 内存: {m['used_gb']}/{m['total_gb']}GB ({m['percent']}%) Swap {m['swap_percent']}%") - if s["temps"]: - flat = [t for entries in s["temps"].values() for t in entries] - hottest = max(flat, key=lambda t: t["current"]) - lines.append(f"🌡 温度: 最高 {hottest['label']} {hottest['current']}°C") - if s["net"]["up_mbps"] is not None: - lines.append(f"🌐 网络: ↑{s['net']['up_mbps']}Mbps ↓{s['net']['down_mbps']}Mbps") - lines.append(f"⏱ 运行: {_fmt_uptime(s['uptime_s'])}") - if s["disk"]: - d = s["disk"][0] - lines.append(f"🗄 磁盘 {d['mount']}: {d['used_gb']}/{d['total_gb']}GB ({d['percent']}%)") - return "\n".join(lines) + c, m = s["cpu"], s["memory"] + hot = _hottest(s["temps"]) + net = s["net"] + d = s["disk"][0] if s["disk"] else None + return templates.render( + "status", + power=_power_str(s["power"]), + cpu_total=f"{sum(c['per_core']):.1f}%", + cpu_max=f"{c['cores'] * 100}%", + cpu_avg=f"{c['percent']}", + cores=c["cores"], + load=f"{c['load'][0]:.2f}/{c['load'][1]:.2f}/{c['load'][2]:.2f}", + memory=f"{m['used_gb']}/{m['total_gb']}GB ({m['percent']}%) Swap {m['swap_percent']}%", + temp=f"{hot['label']} {hot['current']}C" if hot else "无数据", + net=(f"上行 {net['up_mbps']}Mbps / 下行 {net['down_mbps']}Mbps" + if net["up_mbps"] is not None else "无数据"), + disk_mount=d["mount"] if d else "-", + disk_used=f"{d['used_gb']}/{d['total_gb']}GB" if d else "-", + disk_percent=f"{d['percent']}%" if d else "-", + uptime=_fmt_uptime(s["uptime_s"]), + ) def fmt_power(s: dict) -> str: p = s["power"] if p["total_watts"] is None and p["battery"] is None: - return ("⚡ 未获取到功耗数据。\n" - "支持的来源:笔记本电池 /sys/class/power_supply," - "Intel RAPL /sys/class/powercap(AMD/部分虚拟机不支持)。") - lines = ["⚡ 功耗详情"] + return templates.render( + "power", + power=("未获取到功耗数据。支持的来源:笔记本电池 /sys/class/power_supply," + "Intel RAPL /sys/class/powercap (AMD/多数虚拟机不支持)。"), + ) + lines = [] if p["battery"] is not None: - lines.append(f"🔋 电池功率: {p['battery']}W") + lines.append(f"电池功率: {p['battery']}W") if p["domains"]: lines.append("RAPL 域:") - for name, watts in p["domains"].items(): - lines.append(f" {name}: {watts}W") - return "\n".join(lines) + lines += [f" {name}: {watts}W" for name, watts in p["domains"].items()] + return templates.render("power", power="\n".join(lines) or "无数据") def fmt_top(s: dict) -> str: if not s["top"]: - return "🔝 未获取到进程数据" - lines = ["🔝 CPU 占用 TOP10:"] - for i, p in enumerate(s["top"], 1): - lines.append(f"{i}. {p['name']} (PID {p['pid']}) CPU {p['cpu']}% 内存 {p['mem_mb']}MB") - return "\n".join(lines) + return templates.render("top", top_n=10, top_list="无数据") + lines = [ + f"{i}. {p['name']} (PID {p['pid']}) CPU {p['cpu']}% 内存 {p['mem_mb']}MB" + for i, p in enumerate(s["top"], 1) + ] + return templates.render("top", top_n=len(s["top"]), top_list="\n".join(lines)) def fmt_temps(s: dict) -> str: if not s["temps"]: - return "🌡 未获取到温度数据(需要 lm-sensors 或 psutil 支持,虚拟机通常不支持)" - lines = ["🌡 温度:"] - for name, entries in s["temps"].items(): - for t in entries: - lines.append(f" {name}/{t['label']}: {t['current']}°C") - return "\n".join(lines) + return templates.render("temps", temp_list="未获取到 (需要 lm-sensors,虚拟机通常不支持)") + lines = [ + f" {name}/{t['label']}: {t['current']}C" + for name, entries in s["temps"].items() for t in entries + ] + return templates.render("temps", temp_list="\n".join(lines)) -HELP = """📖 可用命令(/ 或 ! 前缀均可): -/status 状态 - 总览(功耗/CPU/内存/温度/网络) -/power 功耗 - 功耗详情(RAPL/电池) -/top 进程 - CPU TOP10 进程 -/temp 温度 - 传感器温度 -/net 网络 - 网络速率 -/help 帮助 - 本帮助""" +def fmt_net(net: dict) -> str: + return templates.render( + "net", up=f"{net['up_mbps']}Mbps", down=f"{net['down_mbps']}Mbps") # ---------- 命令处理 ---------- @@ -177,10 +185,9 @@ class Bot: if cmd in ("temp", "温度"): return fmt_temps({"temps": monitor.get_temps()}) if cmd in ("net", "网络"): - net = monitor.get_net(interval=1.0) - return f"🌐 网络速率: ↑{net['up_mbps']}Mbps ↓{net['down_mbps']}Mbps" + return fmt_net(monitor.get_net(interval=1.0)) if cmd in ("help", "帮助"): - return HELP + return templates.render("help") return None except Exception: log.exception("处理命令失败: %s", text) diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..4eadc11 --- /dev/null +++ b/templates.py @@ -0,0 +1,21 @@ +"""模板渲染:从 templates/ 目录加载 .txt 模板并用 str.format 填充。 + +查找顺序:本模块所在目录、当前工作目录。 +""" +from pathlib import Path + +_CANDIDATE_DIRS = [Path(__file__).parent / "templates", Path("templates")] +_cache: dict[str, str] = {} + + +def _dir() -> Path: + for d in _CANDIDATE_DIRS: + if d.is_dir(): + return d + raise FileNotFoundError("未找到 templates/ 目录") + + +def render(name: str, **kwargs) -> str: + if name not in _cache: + _cache[name] = (_dir() / f"{name}.txt").read_text(encoding="utf-8") + return _cache[name].format(**kwargs) diff --git a/templates/alert.txt b/templates/alert.txt new file mode 100644 index 0000000..6a95d68 --- /dev/null +++ b/templates/alert.txt @@ -0,0 +1,5 @@ +[高负载告警] CPU 持续超过 {threshold}%,当前 {cpu}% +核心: {cores} 负载: {load} + +TOP{top_n} 高负载进程: +{top_list} diff --git a/templates/help.txt b/templates/help.txt new file mode 100644 index 0000000..d3a6e08 --- /dev/null +++ b/templates/help.txt @@ -0,0 +1,7 @@ +可用命令(/ 或 ! 前缀均可,群聊可 @机器人): +/status 状态 - 总览(功耗/CPU/内存/温度/网络) +/power 功耗 - 功耗详情(RAPL/电池) +/top 进程 - CPU TOP10 进程 +/temp 温度 - 传感器温度 +/net 网络 - 网络速率 +/help 帮助 - 本帮助 diff --git a/templates/net.txt b/templates/net.txt new file mode 100644 index 0000000..6107bbb --- /dev/null +++ b/templates/net.txt @@ -0,0 +1 @@ +网络速率: 上行 {up} / 下行 {down} diff --git a/templates/power.txt b/templates/power.txt new file mode 100644 index 0000000..a81db7b --- /dev/null +++ b/templates/power.txt @@ -0,0 +1,2 @@ +功耗详情 +{power} diff --git a/templates/recovered.txt b/templates/recovered.txt new file mode 100644 index 0000000..3e89d8c --- /dev/null +++ b/templates/recovered.txt @@ -0,0 +1 @@ +[恢复] 服务器 CPU 已回落到 {cpu}%,负载恢复正常。 diff --git a/templates/status.txt b/templates/status.txt new file mode 100644 index 0000000..96657a1 --- /dev/null +++ b/templates/status.txt @@ -0,0 +1,8 @@ +服务器状态 +功耗: {power} +CPU: {cpu_total} / {cpu_max} (单核均值 {cpu_avg}%) 核心 {cores} 负载 {load} +内存: {memory} +最高温度: {temp} +网络: {net} +磁盘 {disk_mount}: {disk_used} ({disk_percent}%) +运行: {uptime} diff --git a/templates/temps.txt b/templates/temps.txt new file mode 100644 index 0000000..e4bbca6 --- /dev/null +++ b/templates/temps.txt @@ -0,0 +1,2 @@ +传感器温度: +{temp_list} diff --git a/templates/top.txt b/templates/top.txt new file mode 100644 index 0000000..72dd8a5 --- /dev/null +++ b/templates/top.txt @@ -0,0 +1,2 @@ +CPU 占用 TOP{top_n}: +{top_list}