mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-08-20 06:35:47 +02:00
1441 lines
60 KiB
Python
1441 lines
60 KiB
Python
# coding: utf-8
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
if "/www/server/panel/class" not in sys.path:
|
||
sys.path.insert(0, "/www/server/panel/class")
|
||
|
||
import public
|
||
|
||
|
||
DEFAULT_SNAPSHOT_RETENTION_DAYS = 7
|
||
MIN_SNAPSHOT_RETENTION_DAYS = 1
|
||
MAX_SNAPSHOT_RETENTION_DAYS = 3650
|
||
|
||
|
||
def _json_dumps(data: Any) -> str:
|
||
try:
|
||
return json.dumps(data)
|
||
except Exception:
|
||
return "{}"
|
||
|
||
|
||
def _json_loads(data: Any, default: Any) -> Any:
|
||
if isinstance(data, (dict, list)):
|
||
return data
|
||
if not isinstance(data, str) or not data:
|
||
return default
|
||
try:
|
||
return json.loads(data)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||
try:
|
||
if isinstance(value, str):
|
||
value = value.strip().replace("%", "")
|
||
return float(value)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _to_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(float(value))
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _to_bytes(value: Any) -> int:
|
||
if isinstance(value, (int, float)):
|
||
return int(value)
|
||
if not isinstance(value, str):
|
||
return 0
|
||
value = value.strip().replace(",", "")
|
||
if not value:
|
||
return 0
|
||
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s*([KMGTPE]?)(?:I?B)?$", value, re.I)
|
||
if not match:
|
||
return _to_int(value)
|
||
number = _to_float(match.group(1))
|
||
unit = match.group(2).upper()
|
||
units = {
|
||
"": 1,
|
||
"K": 1024,
|
||
"M": 1024 ** 2,
|
||
"G": 1024 ** 3,
|
||
"T": 1024 ** 4,
|
||
"P": 1024 ** 5,
|
||
"E": 1024 ** 6,
|
||
}
|
||
return int(number * units.get(unit, 1))
|
||
|
||
|
||
def _to_bytes_from_panel_mem(value: Any) -> int:
|
||
"""面板获取网络内存数值单位通常为 MB,Shell 探针返回单位可能为字节,统一单位"""
|
||
number = _to_float(value)
|
||
if number <= 0:
|
||
return 0
|
||
if number < 1024 * 1024:
|
||
return int(number * 1024 * 1024)
|
||
return int(number)
|
||
|
||
|
||
def _percent(value: Any) -> float:
|
||
return round(_to_float(value), 2)
|
||
|
||
|
||
def _normalize_retention_days(value: Any) -> int:
|
||
days = _to_int(value, DEFAULT_SNAPSHOT_RETENTION_DAYS)
|
||
if days < MIN_SNAPSHOT_RETENTION_DAYS:
|
||
return MIN_SNAPSHOT_RETENTION_DAYS
|
||
if days > MAX_SNAPSHOT_RETENTION_DAYS:
|
||
return MAX_SNAPSHOT_RETENTION_DAYS
|
||
return days
|
||
|
||
|
||
TABLE_SCHEMAS = {
|
||
# 保存每个节点的监控总开关、采集周期(默认采集,在线状态检测,详细指标采集 间隔)、费用(金额,币种代码/符号,费用周期)、到期时间
|
||
"node_monitor_setting": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL"),
|
||
("enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||
("collect_interval", "INTEGER NOT NULL DEFAULT 60"),
|
||
("status_interval", "INTEGER NOT NULL DEFAULT 60"),
|
||
("detail_interval", "INTEGER NOT NULL DEFAULT 60"),
|
||
("snapshot_retention_days", "INTEGER NOT NULL DEFAULT 7"),
|
||
("cost_amount", "REAL NOT NULL DEFAULT 0"),
|
||
("cost_currency", "TEXT NOT NULL DEFAULT 'CNY'"),
|
||
("cost_symbol", "TEXT NOT NULL DEFAULT ''"),
|
||
("cost_period", "TEXT NOT NULL DEFAULT 'month'"),
|
||
("expire_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("remark", "TEXT NOT NULL DEFAULT ''"),
|
||
("created_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("updated_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_monitor_setting_node", True, ("node_id",)),
|
||
],
|
||
},
|
||
# 保存节点选择监控哪个磁盘、哪个网卡、哪些服务 target_type:disk,nic,service target_key:对象唯一标识 extra:JSON 扩展字段 is_primary:标记展示的磁盘网卡
|
||
"node_monitor_target": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL"),
|
||
("target_type", "TEXT NOT NULL"),
|
||
("target_key", "TEXT NOT NULL"),
|
||
("target_name", "TEXT NOT NULL DEFAULT ''"),
|
||
("enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||
("is_primary", "INTEGER NOT NULL DEFAULT 0"),
|
||
("sort", "INTEGER NOT NULL DEFAULT 0"),
|
||
("extra", "TEXT NOT NULL DEFAULT '{}'"),
|
||
("discovered_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("updated_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_monitor_target_unique", True, ("node_id", "target_type", "target_key")),
|
||
("idx_node_monitor_target_node_type", False, ("node_id", "target_type")),
|
||
],
|
||
},
|
||
# 保存每个节点最新监控快照 每分钟覆盖写 status:-1在线 -0离线 -2采集失败但未判定离线 -4重启等待中 {}_json:当前节点全部磁盘网卡服务数据
|
||
"node_monitor_latest": {
|
||
"columns": [
|
||
("node_id", "INTEGER PRIMARY KEY"),
|
||
("ts", "INTEGER NOT NULL DEFAULT 0"),
|
||
("status", "INTEGER NOT NULL DEFAULT 0"),
|
||
("error_msg", "TEXT NOT NULL DEFAULT ''"),
|
||
("cpu_usage", "REAL NOT NULL DEFAULT 0"),
|
||
("mem_usage", "REAL NOT NULL DEFAULT 0"),
|
||
("mem_used", "INTEGER NOT NULL DEFAULT 0"),
|
||
("mem_total", "INTEGER NOT NULL DEFAULT 0"),
|
||
("load1", "REAL NOT NULL DEFAULT 0"),
|
||
("load5", "REAL NOT NULL DEFAULT 0"),
|
||
("load15", "REAL NOT NULL DEFAULT 0"),
|
||
("disk_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("nic_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("service_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("raw_json", "TEXT NOT NULL DEFAULT '{}'"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_monitor_latest_ts", False, ("ts",)),
|
||
],
|
||
},
|
||
# 保存历史监控数据,用于趋势图、平均值告警、持续时间判断
|
||
# 每分钟一条,一台节点一天 1440 条,100 台节点一天 14.4 万条。
|
||
# 保留策略:
|
||
# 分钟级数据保留 7 天
|
||
# 5 分钟聚合数据保留 30 天
|
||
# 超期数据定时清理
|
||
|
||
"node_monitor_snapshot": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL"),
|
||
("ts", "INTEGER NOT NULL"),
|
||
("status", "INTEGER NOT NULL DEFAULT 0"),
|
||
("error_msg", "TEXT NOT NULL DEFAULT ''"),
|
||
("cpu_usage", "REAL NOT NULL DEFAULT 0"),
|
||
("mem_usage", "REAL NOT NULL DEFAULT 0"),
|
||
("mem_used", "INTEGER NOT NULL DEFAULT 0"),
|
||
("mem_total", "INTEGER NOT NULL DEFAULT 0"),
|
||
("load1", "REAL NOT NULL DEFAULT 0"),
|
||
("load5", "REAL NOT NULL DEFAULT 0"),
|
||
("load15", "REAL NOT NULL DEFAULT 0"),
|
||
("disk_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("nic_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("service_json", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("raw_json", "TEXT NOT NULL DEFAULT '{}'"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_monitor_snapshot_node_ts", False, ("node_id", "ts")),
|
||
("idx_node_monitor_snapshot_ts", False, ("ts",)),
|
||
],
|
||
},
|
||
# 节点告警设置 通用设置 告警通道 告警最小间隔 允许告警时间范围
|
||
"node_alert_setting": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL DEFAULT 0"),
|
||
("sender_ids", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("send_interval_enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||
("send_interval", "INTEGER NOT NULL DEFAULT 1800"),
|
||
("time_range_enabled", "INTEGER NOT NULL DEFAULT 0"),
|
||
("time_range_start", "INTEGER NOT NULL DEFAULT 0"),
|
||
("time_range_end", "INTEGER NOT NULL DEFAULT 86400"),
|
||
("created_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("updated_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_alert_setting_node", True, ("node_id",)),
|
||
],
|
||
},
|
||
|
||
# 固定监控项配置 每个节点或全局(node_id=0) 每个metric监控指标 一条
|
||
# target_key:磁盘路径或网卡名. CPU/内存/离线/到期为空
|
||
# duration_minutes:持续多少分钟触发
|
||
# threshold 阈值 xx 超过xx% xxMB/s xxGB xxMB
|
||
# threshold_unit % MB/s GB
|
||
|
||
"node_alert_item": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL DEFAULT 0"),
|
||
("metric", "TEXT NOT NULL"),
|
||
("enabled", "INTEGER NOT NULL DEFAULT 0"),
|
||
("operator", "TEXT NOT NULL DEFAULT '>='"),
|
||
("threshold", "REAL NOT NULL DEFAULT 0"),
|
||
("threshold_unit", "TEXT NOT NULL DEFAULT ''"),
|
||
("duration_minutes", "INTEGER NOT NULL DEFAULT 1"),
|
||
("target_key", "TEXT NOT NULL DEFAULT ''"),
|
||
("target_name", "TEXT NOT NULL DEFAULT ''"),
|
||
("created_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("updated_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_alert_item_unique", True, ("node_id", "metric", "target_key")),
|
||
("idx_node_alert_item_node", False, ("node_id",)),
|
||
("idx_node_alert_item_enabled", False, ("enabled",)),
|
||
],
|
||
},
|
||
|
||
# 保存当前告警状态 避免重复发送
|
||
# status:normal / alert。
|
||
# first_trigger_at:用于持续时间判断
|
||
# last_sent_at:用于最小发送间隔
|
||
# send_count:告警次数 后续可做次数限制
|
||
"node_alert_state": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL"),
|
||
("item_id", "INTEGER NOT NULL DEFAULT 0"),
|
||
("metric", "TEXT NOT NULL DEFAULT ''"),
|
||
("target_key", "TEXT NOT NULL DEFAULT ''"),
|
||
("status", "TEXT NOT NULL DEFAULT 'normal'"),
|
||
("last_value", "REAL NOT NULL DEFAULT 0"),
|
||
("last_message", "TEXT NOT NULL DEFAULT ''"),
|
||
("first_trigger_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("last_checked_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("last_sent_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
("send_count", "INTEGER NOT NULL DEFAULT 0"),
|
||
("updated_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_alert_state_node_metric_target", True, ("node_id", "metric", "target_key")),
|
||
("idx_node_alert_state_v2_node", False, ("node_id",)),
|
||
("idx_node_alert_state_v2_status", False, ("status",)),
|
||
],
|
||
},
|
||
# 告警历史
|
||
"node_alert_history": {
|
||
"columns": [
|
||
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
|
||
("node_id", "INTEGER NOT NULL"),
|
||
("item_id", "INTEGER NOT NULL DEFAULT 0"),
|
||
("metric", "TEXT NOT NULL DEFAULT ''"),
|
||
("target_key", "TEXT NOT NULL DEFAULT ''"),
|
||
("trigger_value", "REAL NOT NULL DEFAULT 0"),
|
||
("operator", "TEXT NOT NULL DEFAULT ''"),
|
||
("threshold", "REAL NOT NULL DEFAULT 0"),
|
||
("threshold_unit", "TEXT NOT NULL DEFAULT ''"),
|
||
("action", "TEXT NOT NULL DEFAULT ''"),
|
||
("message", "TEXT NOT NULL DEFAULT ''"),
|
||
("sender_ids", "TEXT NOT NULL DEFAULT '[]'"),
|
||
("send_result", "TEXT NOT NULL DEFAULT '{}'"),
|
||
("created_at", "INTEGER NOT NULL DEFAULT 0"),
|
||
],
|
||
"indexes": [
|
||
("idx_node_alert_history_v2_node_time", False, ("node_id", "created_at")),
|
||
("idx_node_alert_history_v2_metric_time", False, ("metric", "created_at")),
|
||
("idx_node_alert_history_v2_time", False, ("created_at",)),
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
class NodeMonitorDB:
|
||
_DB_FILE = public.get_panel_path() + "/data/db/node_monitor.db"
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||
self.close()
|
||
|
||
def __del__(self):
|
||
self.close()
|
||
|
||
def table(self, table_name: str):
|
||
return public.S(table_name, self._DB_FILE)
|
||
|
||
@classmethod
|
||
def _column_map(cls, table_name: str) -> Dict[str, str]:
|
||
schema = TABLE_SCHEMAS[table_name]
|
||
return {name: definition for name, definition in schema["columns"]}
|
||
|
||
@staticmethod
|
||
def _create_table_sql(table_name: str) -> str:
|
||
columns = ["`{}` {}".format(name, definition) for name, definition in TABLE_SCHEMAS[table_name]["columns"]]
|
||
return "CREATE TABLE IF NOT EXISTS `{}` (\n {}\n);".format(table_name, ",\n ".join(columns))
|
||
|
||
@staticmethod
|
||
def _create_index_sql(table_name: str, index_name: str, unique: bool, columns: Tuple[str, ...]) -> str:
|
||
unique_sql = "UNIQUE " if unique else ""
|
||
col_sql = ", ".join("`{}`".format(item) for item in columns)
|
||
return "CREATE {}INDEX IF NOT EXISTS `{}` ON `{}` ({});".format(unique_sql, index_name, table_name, col_sql)
|
||
|
||
@staticmethod
|
||
def _sqlite_table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
||
row = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||
(table_name,),
|
||
).fetchone()
|
||
return bool(row)
|
||
|
||
@staticmethod
|
||
def _sqlite_columns(conn: sqlite3.Connection, table_name: str) -> List[str]:
|
||
if not NodeMonitorDB._sqlite_table_exists(conn, table_name):
|
||
return []
|
||
return [row[1] for row in conn.execute("PRAGMA table_info(`{}`)".format(table_name)).fetchall()]
|
||
|
||
@staticmethod
|
||
def _archive_sqlite_table(conn: sqlite3.Connection, table_name: str, suffix: int) -> None:
|
||
if not NodeMonitorDB._sqlite_table_exists(conn, table_name):
|
||
return
|
||
new_name = "{}_legacy_{}".format(table_name, suffix)
|
||
while NodeMonitorDB._sqlite_table_exists(conn, new_name):
|
||
suffix += 1
|
||
new_name = "{}_legacy_{}".format(table_name, suffix)
|
||
conn.execute("ALTER TABLE `{}` RENAME TO `{}`".format(table_name, new_name))
|
||
|
||
@staticmethod
|
||
def _archive_legacy_alert_tables(conn: sqlite3.Connection) -> None:
|
||
suffix = int(time.time())
|
||
if NodeMonitorDB._sqlite_table_exists(conn, "node_alert_rule"):
|
||
NodeMonitorDB._archive_sqlite_table(conn, "node_alert_rule", suffix)
|
||
state_columns = NodeMonitorDB._sqlite_columns(conn, "node_alert_state")
|
||
if "rule_id" in state_columns:
|
||
NodeMonitorDB._archive_sqlite_table(conn, "node_alert_state", suffix)
|
||
history_columns = NodeMonitorDB._sqlite_columns(conn, "node_alert_history")
|
||
if "rule_id" in history_columns or "value" in history_columns:
|
||
NodeMonitorDB._archive_sqlite_table(conn, "node_alert_history", suffix)
|
||
|
||
# 初始化数据库 支持新增字段(在表定义中添加
|
||
def init_db(self):
|
||
db_dir = os.path.dirname(self._DB_FILE)
|
||
if not os.path.exists(db_dir):
|
||
os.makedirs(db_dir)
|
||
if not os.path.exists(self._DB_FILE):
|
||
public.writeFile(self._DB_FILE, "")
|
||
|
||
conn = sqlite3.connect(self._DB_FILE)
|
||
try:
|
||
conn.execute("PRAGMA busy_timeout=5000;")
|
||
try:
|
||
conn.execute("PRAGMA journal_mode=WAL;")
|
||
except Exception:
|
||
pass
|
||
# 不用兼容旧数据
|
||
# self._archive_legacy_alert_tables(conn)
|
||
cur = conn.cursor()
|
||
for table_name in TABLE_SCHEMAS:
|
||
cur.execute(self._create_table_sql(table_name))
|
||
cur.execute("PRAGMA table_info(`{}`)".format(table_name))
|
||
existing_cols = {row[1] for row in cur.fetchall()}
|
||
for col_name, col_def in TABLE_SCHEMAS[table_name]["columns"]:
|
||
if col_name in existing_cols:
|
||
continue
|
||
if "PRIMARY KEY" in col_def.upper():
|
||
continue
|
||
cur.execute("ALTER TABLE `{}` ADD COLUMN `{}` {}".format(table_name, col_name, col_def))
|
||
|
||
for index_name, unique, columns in TABLE_SCHEMAS[table_name].get("indexes", []):
|
||
cur.execute(self._create_index_sql(table_name, index_name, unique, columns))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_setting(self, node_id: int, create: bool = True) -> Dict[str, Any]:
|
||
node_id = int(node_id)
|
||
data = self.table("node_monitor_setting").where("node_id=?", (node_id,)).find()
|
||
if isinstance(data, dict) and data:
|
||
return data
|
||
if not create:
|
||
return {}
|
||
now = int(time.time())
|
||
row = {
|
||
"node_id": node_id,
|
||
"enabled": 1,
|
||
"collect_interval": 60,
|
||
"status_interval": 60,
|
||
"detail_interval": 60,
|
||
"snapshot_retention_days": DEFAULT_SNAPSHOT_RETENTION_DAYS,
|
||
"cost_amount": 0,
|
||
"cost_currency": "CNY",
|
||
"cost_symbol": "",
|
||
"cost_period": "month",
|
||
"expire_at": 0,
|
||
"remark": "",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
self.table("node_monitor_setting").insert(row)
|
||
return self.table("node_monitor_setting").where("node_id=?", (node_id,)).find() or row
|
||
|
||
def save_setting(self, node_id: int, data: Dict[str, Any]) -> str:
|
||
node_id = int(node_id)
|
||
allowed = {
|
||
"enabled", "collect_interval", "status_interval", "detail_interval",
|
||
"snapshot_retention_days",
|
||
"cost_amount", "cost_currency", "cost_symbol", "cost_period",
|
||
"expire_at", "remark",
|
||
}
|
||
row = {}
|
||
for key, value in data.items():
|
||
if key not in allowed:
|
||
continue
|
||
if key == "snapshot_retention_days":
|
||
value = _normalize_retention_days(value)
|
||
row[key] = value
|
||
if not row:
|
||
return ""
|
||
row["updated_at"] = int(time.time())
|
||
if not self.get_setting(node_id, create=False):
|
||
self.get_setting(node_id, create=True)
|
||
res = self.table("node_monitor_setting").where("node_id=?", (node_id,)).update(row)
|
||
return res if isinstance(res, str) else ""
|
||
|
||
def get_targets(self, node_id: int, target_type: str = "") -> List[Dict[str, Any]]:
|
||
query = self.table("node_monitor_target")
|
||
if target_type:
|
||
data = query.where("node_id=? AND target_type=?", (int(node_id), target_type)).order("sort", "ASC").order("id", "ASC").select()
|
||
else:
|
||
data = query.where("node_id=?", (int(node_id),)).order("target_type", "ASC").order("sort", "ASC").order("id", "ASC").select()
|
||
if isinstance(data, list):
|
||
for item in data:
|
||
item["extra"] = _json_loads(item.get("extra"), {})
|
||
return data
|
||
return []
|
||
|
||
def get_enabled_service_targets(self, node_id: int) -> List[Dict[str, Any]]:
|
||
return [item for item in self.get_targets(node_id, "service") if int(item.get("enabled", 0)) == 1]
|
||
|
||
def upsert_target(self, node_id: int, target_type: str, target_key: str, data: Dict[str, Any]) -> str:
|
||
node_id = int(node_id)
|
||
target_type = str(target_type)
|
||
target_key = str(target_key)
|
||
now = int(time.time())
|
||
row = {
|
||
"target_name": data.get("target_name", ""),
|
||
"enabled": int(data.get("enabled", 1)),
|
||
"is_primary": int(data.get("is_primary", 0)),
|
||
"sort": int(data.get("sort", 0)),
|
||
"extra": _json_dumps(data.get("extra", {})),
|
||
"updated_at": now,
|
||
}
|
||
old = self.table("node_monitor_target").where(
|
||
"node_id=? AND target_type=? AND target_key=?", (node_id, target_type, target_key)
|
||
).find()
|
||
try:
|
||
if isinstance(old, dict) and old:
|
||
self.table("node_monitor_target").where("id=?", (old["id"],)).update(row)
|
||
else:
|
||
row.update({
|
||
"node_id": node_id,
|
||
"target_type": target_type,
|
||
"target_key": target_key,
|
||
"discovered_at": now,
|
||
})
|
||
self.table("node_monitor_target").insert(row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def set_primary_target(self, node_id: int, target_type: str, target_key: str) -> str:
|
||
if target_type not in ("disk", "nic"):
|
||
return "Only disk or nic can be set as the primary target"
|
||
node_id = int(node_id)
|
||
target_key = str(target_key)
|
||
self.table("node_monitor_target").where(
|
||
"node_id=? AND target_type=?", (node_id, target_type)
|
||
).update({"is_primary": 0, "updated_at": int(time.time())})
|
||
old = self.table("node_monitor_target").where(
|
||
"node_id=? AND target_type=? AND target_key=?", (node_id, target_type, target_key)
|
||
).find()
|
||
if not isinstance(old, dict) or not old:
|
||
err = self.upsert_target(node_id, target_type, target_key, {
|
||
"target_name": target_key,
|
||
"enabled": 1,
|
||
"is_primary": 1,
|
||
})
|
||
return err
|
||
res = self.table("node_monitor_target").where("id=?", (old["id"],)).update({
|
||
"enabled": 1,
|
||
"is_primary": 1,
|
||
"updated_at": int(time.time()),
|
||
})
|
||
return res if isinstance(res, str) else ""
|
||
|
||
def save_service_targets(self, node_id: int, services: List[Dict[str, Any]]) -> str:
|
||
node_id = int(node_id)
|
||
old = self.get_targets(node_id, "service")
|
||
old_map = {item["target_key"]: item for item in old}
|
||
seen = set()
|
||
for idx, item in enumerate(services):
|
||
key = str(item.get("target_key") or item.get("name") or "").strip()
|
||
if not key:
|
||
continue
|
||
seen.add(key)
|
||
err = self.upsert_target(node_id, "service", key, {
|
||
"target_name": item.get("target_name", key),
|
||
"enabled": int(item.get("enabled", 1)),
|
||
"is_primary": 0,
|
||
"sort": int(item.get("sort", idx)),
|
||
"extra": item.get("extra", {}),
|
||
})
|
||
if err:
|
||
return err
|
||
now = int(time.time())
|
||
for key, item in old_map.items():
|
||
if key not in seen:
|
||
self.table("node_monitor_target").where("id=?", (item["id"],)).update({
|
||
"enabled": 0,
|
||
"updated_at": now,
|
||
})
|
||
return ""
|
||
|
||
def sync_discovered_targets(self, node_id: int, disks: List[Dict[str, Any]], nics: List[Dict[str, Any]]) -> None:
|
||
self._sync_discovered_by_type(node_id, "disk", disks, "path")
|
||
self._sync_discovered_by_type(node_id, "nic", nics, "name")
|
||
|
||
def _sync_discovered_by_type(self, node_id: int, target_type: str, items: List[Dict[str, Any]], key_name: str) -> None:
|
||
if not items:
|
||
return
|
||
current = self.get_targets(node_id, target_type)
|
||
current_keys = {item["target_key"] for item in current}
|
||
has_primary = any(int(item.get("is_primary", 0)) == 1 for item in current)
|
||
for idx, item in enumerate(items):
|
||
key = str(item.get(key_name, "")).strip()
|
||
if not key:
|
||
continue
|
||
is_primary = 1 if (not has_primary and idx == 0) else 0
|
||
has_primary = has_primary or bool(is_primary)
|
||
if key in current_keys:
|
||
old = next((row for row in current if row["target_key"] == key), None)
|
||
if old:
|
||
self.table("node_monitor_target").where("id=?", (old["id"],)).update({
|
||
"target_name": item.get("name", key),
|
||
"extra": _json_dumps(item),
|
||
"updated_at": int(time.time()),
|
||
})
|
||
continue
|
||
self.upsert_target(node_id, target_type, key, {
|
||
"target_name": item.get("name", key),
|
||
"enabled": 1,
|
||
"is_primary": is_primary,
|
||
"sort": idx,
|
||
"extra": item,
|
||
})
|
||
|
||
def normalize_monitor_data(self, raw_data: Dict[str, Any], status: int = 1, error_msg: str = "") -> Dict[str, Any]:
|
||
now = int(time.time())
|
||
cpu_usage = 0.0
|
||
cpu_data = raw_data.get("cpu", [])
|
||
if isinstance(cpu_data, (list, tuple)) and cpu_data:
|
||
cpu_usage = _percent(cpu_data[0])
|
||
elif isinstance(cpu_data, dict):
|
||
cpu_usage = _percent(cpu_data.get("usage", cpu_data.get("used", 0)))
|
||
|
||
mem = raw_data.get("mem", {}) if isinstance(raw_data.get("mem", {}), dict) else {}
|
||
mem_used = _to_bytes_from_panel_mem(mem.get("memRealUsed", mem.get("used", 0)))
|
||
mem_total = _to_bytes_from_panel_mem(mem.get("memTotal", mem.get("total", 0)))
|
||
mem_usage = round(mem_used / mem_total * 100, 2) if mem_total else 0.0
|
||
|
||
load = raw_data.get("load", {}) if isinstance(raw_data.get("load", {}), dict) else {}
|
||
disks = self._normalize_disks(raw_data)
|
||
nics = self._normalize_nics(raw_data)
|
||
services = raw_data.get("services", [])
|
||
if not isinstance(services, list):
|
||
services = []
|
||
else:
|
||
services = self._normalize_services(services)
|
||
|
||
normalized = {
|
||
"ts": now,
|
||
"status": int(status),
|
||
"error_msg": str(error_msg or ""),
|
||
"cpu_usage": cpu_usage,
|
||
"mem_usage": mem_usage,
|
||
"mem_used": mem_used,
|
||
"mem_total": mem_total,
|
||
"load1": _to_float(load.get("one", load.get("load1", 0))),
|
||
"load5": _to_float(load.get("five", load.get("load5", 0))),
|
||
"load15": _to_float(load.get("fifteen", load.get("load15", 0))),
|
||
"disk_json": _json_dumps(disks),
|
||
"nic_json": _json_dumps(nics),
|
||
"service_json": _json_dumps(services),
|
||
"raw_json": _json_dumps(raw_data),
|
||
}
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _normalize_disks(raw_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||
disk_data = raw_data.get("disk", raw_data.get("disks", []))
|
||
if not isinstance(disk_data, list):
|
||
return []
|
||
result = []
|
||
for item in disk_data:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
byte_size = item.get("byte_size", [])
|
||
total = used = available = 0
|
||
if isinstance(byte_size, (list, tuple)) and len(byte_size) >= 3:
|
||
total, used, available = _to_int(byte_size[0]), _to_int(byte_size[1]), _to_int(byte_size[2])
|
||
elif isinstance(item.get("size"), (list, tuple)) and len(item.get("size")) >= 3:
|
||
size_data = item.get("size")
|
||
total, used, available = _to_bytes(size_data[0]), _to_bytes(size_data[1]), _to_bytes(size_data[2])
|
||
else:
|
||
total = _to_int(item.get("total", item.get("size_total", 0)))
|
||
used = _to_int(item.get("used", item.get("size_used", 0)))
|
||
available = _to_int(item.get("available", item.get("free", 0)))
|
||
usage = _percent(item.get("usage", item.get("d_size", 0)))
|
||
if not usage and total:
|
||
usage = round(used / total * 100, 2)
|
||
inode_usage = 0.0
|
||
inodes = item.get("inodes", [])
|
||
if isinstance(inodes, (list, tuple)) and len(inodes) >= 4:
|
||
inode_usage = _percent(inodes[3])
|
||
else:
|
||
inode_usage = _percent(item.get("inode_usage", 0))
|
||
result.append({
|
||
"path": item.get("path", item.get("mountpoint", "")),
|
||
"filesystem": item.get("filesystem", item.get("device", "")),
|
||
"type": item.get("type", item.get("types", "")),
|
||
"total": total,
|
||
"used": used,
|
||
"available": available,
|
||
"usage": usage,
|
||
"inode_usage": inode_usage,
|
||
})
|
||
return [item for item in result if item["path"]]
|
||
|
||
@staticmethod
|
||
def _normalize_nics(raw_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||
network = raw_data.get("network", raw_data.get("nics", {}))
|
||
result = []
|
||
if isinstance(network, list):
|
||
items = [(item.get("name", ""), item) for item in network if isinstance(item, dict)]
|
||
elif isinstance(network, dict):
|
||
items = [(name, data) for name, data in network.items() if isinstance(data, dict)]
|
||
else:
|
||
items = []
|
||
for name, item in items:
|
||
if not name or name == "lo":
|
||
continue
|
||
result.append({
|
||
"name": name,
|
||
"up_kbs": _to_float(item.get("up", item.get("up_kbs", 0))),
|
||
"down_kbs": _to_float(item.get("down", item.get("down_kbs", 0))),
|
||
"up_total": _to_int(item.get("upTotal", item.get("up_total", 0))),
|
||
"down_total": _to_int(item.get("downTotal", item.get("down_total", 0))),
|
||
"up_packets": _to_int(item.get("upPackets", item.get("up_packets", 0))),
|
||
"down_packets": _to_int(item.get("downPackets", item.get("down_packets", 0))),
|
||
})
|
||
return result
|
||
|
||
@staticmethod
|
||
def _normalize_services(services: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
result = []
|
||
for item in services:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name") or item.get("target_key") or "").strip()
|
||
if not name:
|
||
continue
|
||
status = _to_int(item.get("status", 1 if item.get("running") else 0))
|
||
result.append({
|
||
"name": name,
|
||
"target_key": str(item.get("target_key") or name),
|
||
"target_name": str(item.get("target_name") or item.get("name") or name),
|
||
"status": status,
|
||
"running": bool(item.get("running", status == 1)),
|
||
"state": str(item.get("state") or ("active" if status == 1 else "inactive")),
|
||
"error_msg": str(item.get("error_msg") or ""),
|
||
"ts": _to_int(item.get("ts"), int(time.time())),
|
||
})
|
||
return result
|
||
|
||
def save_node_snapshot(self, node_id: int, raw_data: Dict[str, Any], status: int = 1, error_msg: str = "") -> str:
|
||
node_id = int(node_id)
|
||
if not isinstance(raw_data, dict):
|
||
raw_data = {}
|
||
row = self.normalize_monitor_data(raw_data, status=status, error_msg=error_msg)
|
||
latest_row = {"node_id": node_id}
|
||
latest_row.update(row)
|
||
snapshot_row = {"node_id": node_id}
|
||
snapshot_row.update(row)
|
||
try:
|
||
exists = self.table("node_monitor_latest").where("node_id=?", (node_id,)).count() > 0
|
||
if exists:
|
||
data = latest_row.copy()
|
||
data.pop("node_id", None)
|
||
self.table("node_monitor_latest").where("node_id=?", (node_id,)).update(data)
|
||
else:
|
||
self.table("node_monitor_latest").insert(latest_row)
|
||
self.table("node_monitor_snapshot").insert(snapshot_row)
|
||
self.sync_discovered_targets(
|
||
node_id,
|
||
_json_loads(row["disk_json"], []),
|
||
_json_loads(row["nic_json"], []),
|
||
)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def save_latest_status(self, node_id: int, status: int, error_msg: str = "") -> str:
|
||
node_id = int(node_id)
|
||
now = int(time.time())
|
||
row = {
|
||
"node_id": node_id,
|
||
"ts": now,
|
||
"status": int(status),
|
||
"error_msg": str(error_msg or ""),
|
||
}
|
||
try:
|
||
exists = self.table("node_monitor_latest").where("node_id=?", (node_id,)).count() > 0
|
||
if exists:
|
||
data = row.copy()
|
||
data.pop("node_id", None)
|
||
self.table("node_monitor_latest").where("node_id=?", (node_id,)).update(data)
|
||
else:
|
||
self.table("node_monitor_latest").insert(row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def save_latest_services(self, node_id: int, services: List[Dict[str, Any]], error_msg: str = "") -> str:
|
||
node_id = int(node_id)
|
||
service_list = self._normalize_services(services if isinstance(services, list) else [])
|
||
try:
|
||
latest = self.get_latest(node_id)
|
||
raw_json = latest.get("raw_json", {}) if latest else {}
|
||
if not isinstance(raw_json, dict):
|
||
raw_json = {}
|
||
raw_json["services"] = service_list
|
||
row = {
|
||
"service_json": _json_dumps(service_list),
|
||
"raw_json": _json_dumps(raw_json),
|
||
}
|
||
if latest:
|
||
self.table("node_monitor_latest").where("node_id=?", (node_id,)).update(row)
|
||
else:
|
||
row.update({
|
||
"node_id": node_id,
|
||
"ts": int(time.time()),
|
||
"status": 2,
|
||
"error_msg": str(error_msg or ""),
|
||
})
|
||
self.table("node_monitor_latest").insert(row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def get_latest(self, node_id: int) -> Dict[str, Any]:
|
||
row = self.table("node_monitor_latest").where("node_id=?", (int(node_id),)).find()
|
||
if not isinstance(row, dict) or not row:
|
||
return {}
|
||
return self._decode_latest_row(row)
|
||
|
||
def _decode_latest_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||
row = dict(row)
|
||
row["disk_json"] = _json_loads(row.get("disk_json"), [])
|
||
row["nic_json"] = _json_loads(row.get("nic_json"), [])
|
||
row["service_json"] = _json_loads(row.get("service_json"), [])
|
||
row["raw_json"] = _json_loads(row.get("raw_json"), {})
|
||
return row
|
||
|
||
def get_latest_view(self, node_id: int) -> Dict[str, Any]:
|
||
latest = self.get_latest(node_id)
|
||
if not latest:
|
||
return {}
|
||
targets = self.get_targets(node_id)
|
||
target_map = {}
|
||
for item in targets:
|
||
target_map.setdefault(item["target_type"], []).append(item)
|
||
|
||
latest["selected_disk"] = self._select_primary(latest["disk_json"], target_map.get("disk", []), "path")
|
||
latest["selected_nic"] = self._select_primary(latest["nic_json"], target_map.get("nic", []), "name")
|
||
service_targets = [item["target_key"] for item in target_map.get("service", []) if int(item.get("enabled", 0)) == 1]
|
||
if service_targets:
|
||
latest["service_json"] = [
|
||
item for item in latest["service_json"]
|
||
if isinstance(item, dict) and (item.get("target_key") in service_targets or item.get("name") in service_targets)
|
||
]
|
||
return latest
|
||
|
||
def get_list_monitor_map(self, node_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||
node_ids = self._normalize_node_ids(node_ids)
|
||
if not node_ids:
|
||
return {}
|
||
|
||
setting_list = []
|
||
latest_list = []
|
||
target_list = []
|
||
for chunk_ids in self._chunk_node_ids(node_ids):
|
||
placeholders = ",".join(["?"] * len(chunk_ids))
|
||
params = tuple(chunk_ids)
|
||
settings = self.table("node_monitor_setting").where(
|
||
"node_id IN ({})".format(placeholders), params
|
||
).select()
|
||
latest = self.table("node_monitor_latest").where(
|
||
"node_id IN ({})".format(placeholders), params
|
||
).select()
|
||
targets = self.table("node_monitor_target").where(
|
||
"node_id IN ({})".format(placeholders), params
|
||
).order("node_id", "ASC").order("target_type", "ASC").order("sort", "ASC").order("id", "ASC").select()
|
||
if isinstance(settings, list):
|
||
setting_list.extend(settings)
|
||
if isinstance(latest, list):
|
||
latest_list.extend(latest)
|
||
if isinstance(targets, list):
|
||
target_list.extend(targets)
|
||
|
||
setting_map = {}
|
||
if isinstance(setting_list, list):
|
||
setting_map = {int(item["node_id"]): item for item in setting_list if isinstance(item, dict)}
|
||
|
||
latest_map = {}
|
||
if isinstance(latest_list, list):
|
||
latest_map = {
|
||
int(item["node_id"]): self._decode_latest_row(item)
|
||
for item in latest_list if isinstance(item, dict)
|
||
}
|
||
|
||
target_map = {node_id: {"disk": [], "nic": [], "service": []} for node_id in node_ids}
|
||
if isinstance(target_list, list):
|
||
for item in target_list:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
node_id = int(item.get("node_id", 0))
|
||
target_type = item.get("target_type")
|
||
if node_id not in target_map or target_type not in target_map[node_id]:
|
||
continue
|
||
item = dict(item)
|
||
item["extra"] = _json_loads(item.get("extra"), {})
|
||
target_map[node_id][target_type].append(item)
|
||
|
||
return {
|
||
node_id: self._build_list_monitor_item(
|
||
node_id,
|
||
setting_map.get(node_id) or self._default_setting_row(node_id),
|
||
latest_map.get(node_id) or {},
|
||
target_map.get(node_id) or {"disk": [], "nic": [], "service": []},
|
||
)
|
||
for node_id in node_ids
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_node_ids(node_ids: List[int]) -> List[int]:
|
||
result = []
|
||
seen = set()
|
||
for node_id in node_ids:
|
||
try:
|
||
node_id = int(node_id)
|
||
except Exception:
|
||
continue
|
||
if node_id <= 0 or node_id in seen:
|
||
continue
|
||
seen.add(node_id)
|
||
result.append(node_id)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _chunk_node_ids(node_ids: List[int], chunk_size: int = 500) -> List[List[int]]:
|
||
return [node_ids[idx:idx + chunk_size] for idx in range(0, len(node_ids), chunk_size)]
|
||
|
||
@staticmethod
|
||
def _default_setting_row(node_id: int) -> Dict[str, Any]:
|
||
now = int(time.time())
|
||
return {
|
||
"id": 0,
|
||
"node_id": int(node_id),
|
||
"enabled": 1,
|
||
"collect_interval": 60,
|
||
"status_interval": 60,
|
||
"detail_interval": 60,
|
||
"snapshot_retention_days": DEFAULT_SNAPSHOT_RETENTION_DAYS,
|
||
"cost_amount": 0,
|
||
"cost_currency": "CNY",
|
||
"cost_symbol": "",
|
||
"cost_period": "month",
|
||
"expire_at": 0,
|
||
"remark": "",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
|
||
def _build_list_monitor_item(
|
||
self,
|
||
node_id: int,
|
||
setting: Dict[str, Any],
|
||
latest: Dict[str, Any],
|
||
targets: Dict[str, List[Dict[str, Any]]],
|
||
) -> Dict[str, Any]:
|
||
disks = latest.get("disk_json", []) if isinstance(latest, dict) else []
|
||
nics = latest.get("nic_json", []) if isinstance(latest, dict) else []
|
||
services = latest.get("service_json", []) if isinstance(latest, dict) else []
|
||
if not isinstance(disks, list):
|
||
disks = []
|
||
if not isinstance(nics, list):
|
||
nics = []
|
||
if not isinstance(services, list):
|
||
services = []
|
||
|
||
selected_disk = self._select_primary(disks, targets.get("disk", []), "path")
|
||
selected_nic = self._select_primary(nics, targets.get("nic", []), "name")
|
||
service_targets = [
|
||
item.get("target_key")
|
||
for item in targets.get("service", [])
|
||
if int(item.get("enabled", 0)) == 1 and item.get("target_key")
|
||
]
|
||
if service_targets:
|
||
service_key_set = set(service_targets)
|
||
services = [
|
||
item for item in services
|
||
if isinstance(item, dict) and (item.get("target_key") in service_key_set or item.get("name") in service_key_set)
|
||
]
|
||
else:
|
||
services = []
|
||
|
||
summary = {
|
||
"ts": _to_int(latest.get("ts", 0)) if latest else 0,
|
||
"status": _to_int(latest.get("status", 0)) if latest else 0,
|
||
"error_msg": str(latest.get("error_msg", "")) if latest else "",
|
||
"cpu_usage": _percent(latest.get("cpu_usage", 0)) if latest else 0,
|
||
"mem_usage": _percent(latest.get("mem_usage", 0)) if latest else 0,
|
||
"mem_used": _to_int(latest.get("mem_used", 0)) if latest else 0,
|
||
"mem_total": _to_int(latest.get("mem_total", 0)) if latest else 0,
|
||
"load1": _to_float(latest.get("load1", 0)) if latest else 0,
|
||
"load5": _to_float(latest.get("load5", 0)) if latest else 0,
|
||
"load15": _to_float(latest.get("load15", 0)) if latest else 0,
|
||
}
|
||
latest_view = dict(summary)
|
||
latest_view.update({
|
||
"selected_disk": selected_disk,
|
||
"selected_nic": selected_nic,
|
||
"services": services,
|
||
})
|
||
|
||
return {
|
||
"enabled": int(setting.get("enabled", 1)),
|
||
"setting": setting,
|
||
"summary": summary,
|
||
"latest": latest_view,
|
||
"selected_disk": selected_disk,
|
||
"selected_nic": selected_nic,
|
||
"disks": disks,
|
||
"nics": nics,
|
||
"services": services,
|
||
"targets": targets,
|
||
"cost": {
|
||
"amount": _to_float(setting.get("cost_amount", 0)),
|
||
"currency": str(setting.get("cost_currency", "")),
|
||
"symbol": str(setting.get("cost_symbol", "")),
|
||
"period": str(setting.get("cost_period", "")),
|
||
},
|
||
"expire": self._build_expire_info(setting.get("expire_at", 0)),
|
||
"has_latest": bool(latest),
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_expire_info(expire_at: Any) -> Dict[str, Any]:
|
||
expire_at = _to_int(expire_at)
|
||
if expire_at <= 0:
|
||
return {
|
||
"expire_at": 0,
|
||
"days_left": None,
|
||
"status": "none",
|
||
"expired": False,
|
||
"is_soon": False,
|
||
}
|
||
now = int(time.time())
|
||
seconds_left = expire_at - now
|
||
if seconds_left >= 0:
|
||
days_left = seconds_left // 86400
|
||
if seconds_left % 86400:
|
||
days_left += 1
|
||
else:
|
||
days_left = -((-seconds_left) // 86400)
|
||
if seconds_left % 86400:
|
||
days_left -= 1
|
||
expired = seconds_left < 0
|
||
is_soon = (not expired) and seconds_left <= 7 * 86400
|
||
return {
|
||
"expire_at": expire_at,
|
||
"days_left": int(days_left),
|
||
"status": "expired" if expired else ("soon" if is_soon else "normal"),
|
||
"expired": expired,
|
||
"is_soon": is_soon,
|
||
}
|
||
|
||
def get_alert_setting(self, node_id: int, create: bool = True) -> Dict[str, Any]:
|
||
node_id = max(_to_int(node_id), 0)
|
||
row = self.table("node_alert_setting").where("node_id=?", (node_id,)).find()
|
||
if isinstance(row, dict) and row:
|
||
return self._decode_alert_setting(row)
|
||
if not create:
|
||
return {}
|
||
now = int(time.time())
|
||
row = self._default_alert_setting_row(node_id)
|
||
row.update({"created_at": now, "updated_at": now})
|
||
self.table("node_alert_setting").insert({
|
||
"node_id": row["node_id"],
|
||
"sender_ids": _json_dumps(row["sender_ids"]),
|
||
"send_interval_enabled": row["send_interval_enabled"],
|
||
"send_interval": row["send_interval"],
|
||
"time_range_enabled": row["time_range_enabled"],
|
||
"time_range_start": row["time_range_start"],
|
||
"time_range_end": row["time_range_end"],
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
})
|
||
data = self.table("node_alert_setting").where("node_id=?", (node_id,)).find()
|
||
return self._decode_alert_setting(data) if isinstance(data, dict) and data else row
|
||
|
||
def save_alert_setting(self, node_id: int, data: Dict[str, Any]) -> str:
|
||
node_id = max(_to_int(node_id), 0)
|
||
row, err = self._normalize_alert_setting_data(data)
|
||
if err:
|
||
return err
|
||
if not row:
|
||
return ""
|
||
now = int(time.time())
|
||
row["updated_at"] = now
|
||
try:
|
||
old = self.get_alert_setting(node_id, create=False)
|
||
if old:
|
||
self.table("node_alert_setting").where("node_id=?", (node_id,)).update(row)
|
||
else:
|
||
default_row = self._default_alert_setting_row(node_id)
|
||
insert_row = {
|
||
"node_id": node_id,
|
||
"sender_ids": _json_dumps(default_row["sender_ids"]),
|
||
"send_interval_enabled": default_row["send_interval_enabled"],
|
||
"send_interval": default_row["send_interval"],
|
||
"time_range_enabled": default_row["time_range_enabled"],
|
||
"time_range_start": default_row["time_range_start"],
|
||
"time_range_end": default_row["time_range_end"],
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
insert_row.update(row)
|
||
self.table("node_alert_setting").insert(insert_row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _default_alert_setting_row(node_id: int) -> Dict[str, Any]:
|
||
return {
|
||
"id": 0,
|
||
"node_id": max(_to_int(node_id), 0),
|
||
"sender_ids": [],
|
||
"send_interval_enabled": 1,
|
||
"send_interval": 1800,
|
||
"time_range_enabled": 0,
|
||
"time_range_start": 0,
|
||
"time_range_end": 86400,
|
||
"created_at": 0,
|
||
"updated_at": 0,
|
||
"scope": "global" if max(_to_int(node_id), 0) == 0 else "node",
|
||
}
|
||
|
||
@staticmethod
|
||
def _decode_alert_setting(row: Dict[str, Any]) -> Dict[str, Any]:
|
||
row = dict(row)
|
||
row["sender_ids"] = NodeMonitorDB._normalize_sender_ids(row.get("sender_ids", []))
|
||
row["scope"] = "global" if _to_int(row.get("node_id", 0)) == 0 else "node"
|
||
return row
|
||
|
||
@staticmethod
|
||
def _normalize_sender_ids(value: Any) -> List[str]:
|
||
if isinstance(value, str):
|
||
value = _json_loads(value, [])
|
||
if not isinstance(value, list):
|
||
return []
|
||
result = []
|
||
seen = set()
|
||
for item in value:
|
||
sender_id = str(item).strip()
|
||
if not sender_id or sender_id in seen:
|
||
continue
|
||
seen.add(sender_id)
|
||
result.append(sender_id)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _normalize_alert_setting_data(data: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
||
row = {}
|
||
if "sender_ids" in data:
|
||
row["sender_ids"] = _json_dumps(NodeMonitorDB._normalize_sender_ids(data.get("sender_ids")))
|
||
if "send_interval_enabled" in data:
|
||
row["send_interval_enabled"] = 1 if _to_int(data.get("send_interval_enabled")) else 0
|
||
if "send_interval" in data:
|
||
row["send_interval"] = max(_to_int(data.get("send_interval"), 1800), 60)
|
||
if "time_range_enabled" in data:
|
||
row["time_range_enabled"] = 1 if _to_int(data.get("time_range_enabled")) else 0
|
||
has_start = "time_range_start" in data
|
||
has_end = "time_range_end" in data
|
||
if has_start:
|
||
row["time_range_start"] = _to_int(data.get("time_range_start"), 0)
|
||
if has_end:
|
||
row["time_range_end"] = _to_int(data.get("time_range_end"), 86400)
|
||
start = row.get("time_range_start", _to_int(data.get("time_range_start", 0)))
|
||
end = row.get("time_range_end", _to_int(data.get("time_range_end", 86400)))
|
||
if has_start or has_end:
|
||
if not (0 <= start <= 86400 and 0 <= end <= 86400):
|
||
return {}, "time range format error"
|
||
if _to_int(data.get("time_range_enabled", row.get("time_range_enabled", 0))) and start == end:
|
||
return {}, "time range format error"
|
||
return row, ""
|
||
|
||
def get_alert_items(self, node_id: int = -1, include_global: bool = False, enabled_only: bool = False) -> List[Dict[str, Any]]:
|
||
where = []
|
||
params = []
|
||
node_id = _to_int(node_id, -1)
|
||
if node_id >= 0:
|
||
if include_global and node_id > 0:
|
||
where.append("(node_id=0 OR node_id=?)")
|
||
params.append(node_id)
|
||
else:
|
||
where.append("node_id=?")
|
||
params.append(node_id)
|
||
if enabled_only:
|
||
where.append("enabled=1")
|
||
query = self.table("node_alert_item")
|
||
if where:
|
||
query = query.where(" AND ".join(where), tuple(params))
|
||
data = query.order("node_id", "ASC").order("metric", "ASC").order("target_key", "ASC").order("id", "ASC").select()
|
||
if not isinstance(data, list):
|
||
return []
|
||
return [self._decode_alert_item(item) for item in data if isinstance(item, dict)]
|
||
|
||
def get_alert_item(self, item_id: int) -> Dict[str, Any]:
|
||
row = self.table("node_alert_item").where("id=?", (_to_int(item_id),)).find()
|
||
if not isinstance(row, dict) or not row:
|
||
return {}
|
||
return self._decode_alert_item(row)
|
||
|
||
def save_alert_item(self, data: Dict[str, Any]) -> Tuple[int, str]:
|
||
row, err = self._normalize_alert_item_data(data)
|
||
if err:
|
||
return 0, err
|
||
item_id = _to_int(data.get("id", data.get("item_id", 0)))
|
||
now = int(time.time())
|
||
row["updated_at"] = now
|
||
try:
|
||
if item_id:
|
||
old = self.get_alert_item(item_id)
|
||
if not old:
|
||
return 0, "Alert item does not exist"
|
||
self.table("node_alert_item").where("id=?", (item_id,)).update(row)
|
||
return item_id, ""
|
||
old = self.table("node_alert_item").where(
|
||
"node_id=? AND metric=? AND target_key=?",
|
||
(row["node_id"], row["metric"], row["target_key"]),
|
||
).find()
|
||
if isinstance(old, dict) and old:
|
||
self.table("node_alert_item").where("id=?", (old["id"],)).update(row)
|
||
return _to_int(old.get("id")), ""
|
||
row["created_at"] = now
|
||
new_id = self.table("node_alert_item").insert(row)
|
||
return _to_int(new_id, 0), "" if not isinstance(new_id, str) else new_id
|
||
except Exception as e:
|
||
return 0, str(e)
|
||
|
||
def save_alert_items(self, node_id: int, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], str]:
|
||
node_id = max(_to_int(node_id), 0)
|
||
if not isinstance(items, list):
|
||
return [], "items format error"
|
||
result = []
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
data = dict(item)
|
||
data["node_id"] = node_id
|
||
item_id, err = self.save_alert_item(data)
|
||
if err:
|
||
return result, err
|
||
result.append(self.get_alert_item(item_id))
|
||
return result, ""
|
||
|
||
def set_alert_item_status(self, item_id: int, enabled: int) -> str:
|
||
item_id = _to_int(item_id)
|
||
if item_id <= 0:
|
||
return "item_id cannot be empty"
|
||
try:
|
||
self.table("node_alert_item").where("id=?", (item_id,)).update({
|
||
"enabled": 1 if _to_int(enabled) else 0,
|
||
"updated_at": int(time.time()),
|
||
})
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def delete_alert_item(self, item_id: int) -> str:
|
||
item_id = _to_int(item_id)
|
||
if item_id <= 0:
|
||
return "item_id cannot be empty"
|
||
try:
|
||
self.table("node_alert_item").where("id=?", (item_id,)).delete()
|
||
self.table("node_alert_state").where("item_id=?", (item_id,)).delete()
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _decode_alert_item(row: Dict[str, Any]) -> Dict[str, Any]:
|
||
row = dict(row)
|
||
row["scope"] = "global" if _to_int(row.get("node_id", 0)) == 0 else "node"
|
||
return row
|
||
|
||
@staticmethod
|
||
def _normalize_alert_item_data(data: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
||
metric = str(data.get("metric", "")).strip()[:64]
|
||
if not metric:
|
||
return {}, "metric cannot be empty"
|
||
operator = str(data.get("operator", ">=")).strip()
|
||
if operator not in (">", ">=", "<", "<=", "==", "!="):
|
||
return {}, "Unsupported alert operator"
|
||
row = {
|
||
"node_id": max(_to_int(data.get("node_id", 0)), 0),
|
||
"metric": metric,
|
||
"enabled": 1 if _to_int(data.get("enabled", 0)) else 0,
|
||
"operator": operator,
|
||
"threshold": _to_float(data.get("threshold", 0)),
|
||
"threshold_unit": str(data.get("threshold_unit", "") or "").strip()[:32],
|
||
"duration_minutes": max(_to_int(data.get("duration_minutes", 1)), 0),
|
||
"target_key": str(data.get("target_key", "") or "").strip()[:255],
|
||
"target_name": str(data.get("target_name", "") or "").strip()[:255],
|
||
}
|
||
return row, ""
|
||
|
||
def get_alert_state(self, node_id: int, metric: str, target_key: str = "") -> Dict[str, Any]:
|
||
row = self.table("node_alert_state").where(
|
||
"node_id=? AND metric=? AND target_key=?",
|
||
(_to_int(node_id), str(metric or ""), str(target_key or "")),
|
||
).find()
|
||
if not isinstance(row, dict) or not row:
|
||
return {}
|
||
return row
|
||
|
||
def upsert_alert_state(self, node_id: int, metric: str, target_key: str, data: Dict[str, Any]) -> str:
|
||
node_id = _to_int(node_id)
|
||
metric = str(metric or "")
|
||
target_key = str(target_key or "")
|
||
now = int(time.time())
|
||
row = {
|
||
"item_id": _to_int(data.get("item_id", 0)),
|
||
"status": str(data.get("status", "normal")),
|
||
"last_value": _to_float(data.get("last_value", 0)),
|
||
"last_message": str(data.get("last_message", ""))[:1000],
|
||
"first_trigger_at": _to_int(data.get("first_trigger_at", 0)),
|
||
"last_checked_at": _to_int(data.get("last_checked_at", now)),
|
||
"last_sent_at": _to_int(data.get("last_sent_at", 0)),
|
||
"send_count": _to_int(data.get("send_count", 0)),
|
||
"updated_at": now,
|
||
}
|
||
try:
|
||
old = self.get_alert_state(node_id, metric, target_key)
|
||
if old:
|
||
self.table("node_alert_state").where("id=?", (old["id"],)).update(row)
|
||
else:
|
||
row.update({
|
||
"node_id": node_id,
|
||
"metric": metric,
|
||
"target_key": target_key,
|
||
})
|
||
self.table("node_alert_state").insert(row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def get_alert_states(self, node_id: int = 0, metric: str = "", status: str = "", item_id: int = 0, limit: int = 1000) -> List[Dict[str, Any]]:
|
||
where = []
|
||
params = []
|
||
if _to_int(node_id):
|
||
where.append("node_id=?")
|
||
params.append(_to_int(node_id))
|
||
if metric:
|
||
where.append("metric=?")
|
||
params.append(str(metric))
|
||
if status:
|
||
where.append("status=?")
|
||
params.append(str(status))
|
||
if _to_int(item_id):
|
||
where.append("item_id=?")
|
||
params.append(_to_int(item_id))
|
||
query = self.table("node_alert_state")
|
||
if where:
|
||
query = query.where(" AND ".join(where), tuple(params))
|
||
data = query.order("updated_at", "DESC").limit(max(1, min(_to_int(limit, 1000), 5000))).select()
|
||
return data if isinstance(data, list) else []
|
||
|
||
def add_alert_history(self, data: Dict[str, Any]) -> str:
|
||
row = {
|
||
"node_id": _to_int(data.get("node_id", 0)),
|
||
"item_id": _to_int(data.get("item_id", 0)),
|
||
"metric": str(data.get("metric", "")),
|
||
"target_key": str(data.get("target_key", "")),
|
||
"trigger_value": _to_float(data.get("trigger_value", 0)),
|
||
"operator": str(data.get("operator", "")),
|
||
"threshold": _to_float(data.get("threshold", 0)),
|
||
"threshold_unit": str(data.get("threshold_unit", "") or "").strip()[:32],
|
||
"action": str(data.get("action", "")),
|
||
"message": str(data.get("message", ""))[:2000],
|
||
"sender_ids": _json_dumps(self._normalize_sender_ids(data.get("sender_ids", []))),
|
||
"send_result": _json_dumps(data.get("send_result", {})),
|
||
"created_at": _to_int(data.get("created_at", int(time.time()))),
|
||
}
|
||
try:
|
||
self.table("node_alert_history").insert(row)
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def get_alert_history(self, node_id: int = 0, metric: str = "", item_id: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
|
||
where = []
|
||
params = []
|
||
if _to_int(node_id):
|
||
where.append("node_id=?")
|
||
params.append(_to_int(node_id))
|
||
if metric:
|
||
where.append("metric=?")
|
||
params.append(str(metric))
|
||
if _to_int(item_id):
|
||
where.append("item_id=?")
|
||
params.append(_to_int(item_id))
|
||
query = self.table("node_alert_history")
|
||
if where:
|
||
query = query.where(" AND ".join(where), tuple(params))
|
||
data = query.order("created_at", "DESC").limit(max(1, min(_to_int(limit, 100), 1000))).select()
|
||
if not isinstance(data, list):
|
||
return []
|
||
for item in data:
|
||
if isinstance(item, dict):
|
||
item["sender_ids"] = _json_loads(item.get("sender_ids"), [])
|
||
item["send_result"] = _json_loads(item.get("send_result"), {})
|
||
return data
|
||
|
||
@staticmethod
|
||
def _select_primary(items: List[Dict[str, Any]], targets: List[Dict[str, Any]], item_key: str) -> Dict[str, Any]:
|
||
if not isinstance(items, list) or not items:
|
||
return {}
|
||
primary_key = ""
|
||
for target in targets:
|
||
if int(target.get("is_primary", 0)) == 1:
|
||
primary_key = target.get("target_key", "")
|
||
break
|
||
if primary_key:
|
||
for item in items:
|
||
if isinstance(item, dict) and item.get(item_key) == primary_key:
|
||
return item
|
||
return items[0] if isinstance(items[0], dict) else {}
|
||
|
||
def get_snapshots(self, node_id: int, start_ts: int = 0, end_ts: int = 0, limit: int = 1440) -> List[Dict[str, Any]]:
|
||
where = ["node_id=?"]
|
||
params = [int(node_id)]
|
||
if start_ts:
|
||
where.append("ts>=?")
|
||
params.append(int(start_ts))
|
||
if end_ts:
|
||
where.append("ts<=?")
|
||
params.append(int(end_ts))
|
||
limit = max(1, min(int(limit), 10080))
|
||
data = self.table("node_monitor_snapshot").where(" AND ".join(where), tuple(params)).order("ts", "DESC").limit(limit).select()
|
||
if not isinstance(data, list):
|
||
return []
|
||
result = [self._decode_latest_row(item) for item in data if isinstance(item, dict)]
|
||
result.reverse()
|
||
return result
|
||
|
||
def cleanup_snapshots(self, before_ts: int) -> str:
|
||
try:
|
||
self.table("node_monitor_snapshot").where("ts<?", (int(before_ts),)).delete()
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|
||
|
||
def cleanup_expired_snapshots(self) -> str:
|
||
now = int(time.time())
|
||
try:
|
||
settings = self.table("node_monitor_setting").field("node_id,snapshot_retention_days").select()
|
||
if not isinstance(settings, list) or not settings:
|
||
return self.cleanup_snapshots(now - DEFAULT_SNAPSHOT_RETENTION_DAYS * 86400)
|
||
cleaned_nodes = set()
|
||
for item in settings:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
node_id = _to_int(item.get("node_id", 0))
|
||
if node_id <= 0 or node_id in cleaned_nodes:
|
||
continue
|
||
cleaned_nodes.add(node_id)
|
||
retention_days = _normalize_retention_days(item.get("snapshot_retention_days"))
|
||
before_ts = now - retention_days * 86400
|
||
self.table("node_monitor_snapshot").where("node_id=? AND ts<?", (node_id, before_ts)).delete()
|
||
except Exception as e:
|
||
return str(e)
|
||
return ""
|