Add log retention settings and clear logs

This commit is contained in:
2026-05-28 23:36:39 +08:00
parent 680cb34000
commit 68ad064f80
5 changed files with 108 additions and 11 deletions
+4 -1
View File
@@ -9,6 +9,7 @@
- 检测间隔、超时、失败/恢复阈值可配置 - 检测间隔、超时、失败/恢复阈值可配置
- 通知渠道:Bark、PushPlus、Autman、Webhook - 通知渠道:Bark、PushPlus、Autman、Webhook
- SQLite 保存检测记录、事件和通知日志 - SQLite 保存检测记录、事件和通知日志
- 可配置单节点最多保留的监测记录数,并可一键清空日志
- Docker / Docker Compose 部署 - Docker / Docker Compose 部署
## 快速启动 ## 快速启动
@@ -43,7 +44,8 @@ python3 app.py --host 0.0.0.0 --port 8787 --config config.json
"interval_seconds": 300, "interval_seconds": 300,
"timeout_seconds": 8, "timeout_seconds": 8,
"fail_threshold": 3, "fail_threshold": 3,
"recover_threshold": 1 "recover_threshold": 1,
"max_checks_per_proxy": 500
} }
``` ```
@@ -96,6 +98,7 @@ Autman 通道适配 `/m/push1` 消息推送插件,发送 JSON:
```text ```text
GET /api/status GET /api/status
PUT /api/settings PUT /api/settings
DELETE /api/logs
POST /api/check/<id> POST /api/check/<id>
POST /api/notify/test POST /api/notify/test
GET /api/proxies GET /api/proxies
+30 -5
View File
@@ -59,6 +59,7 @@ def default_config() -> dict[str, Any]:
"target_port": 443, "target_port": 443,
"fail_threshold": 3, "fail_threshold": 3,
"recover_threshold": 1, "recover_threshold": 1,
"max_checks_per_proxy": 500,
"db_path": str(DEFAULT_DB), "db_path": str(DEFAULT_DB),
"proxies": [], "proxies": [],
"notifications": {"enabled": True, "timeout_seconds": 10, "channels": []}, "notifications": {"enabled": True, "timeout_seconds": 10, "channels": []},
@@ -74,6 +75,7 @@ def merge_defaults(cfg: dict[str, Any]) -> dict[str, Any]:
base["target_host"] = "www.baidu.com" base["target_host"] = "www.baidu.com"
base["target_port"] = int(base.get("target_port") or 443) base["target_port"] = int(base.get("target_port") or 443)
base["interval_seconds"] = int(base.get("interval_seconds") or 300) base["interval_seconds"] = int(base.get("interval_seconds") or 300)
base["max_checks_per_proxy"] = max(1, int(base.get("max_checks_per_proxy") or 500))
base.setdefault("proxies", []) base.setdefault("proxies", [])
for proxy in base["proxies"]: for proxy in base["proxies"]:
if proxy.get("target_host") in (None, "", "1.1.1.1", "8.8.8.8", "google.com"): if proxy.get("target_host") in (None, "", "1.1.1.1", "8.8.8.8", "google.com"):
@@ -180,12 +182,22 @@ class Store:
) )
self.conn.commit() self.conn.commit()
async def add_check(self, proxy_id: str, ok: bool, latency_ms: int | None, error: str | None, exit_ip: str | None) -> None: async def add_check(self, proxy_id: str, ok: bool, latency_ms: int | None, error: str | None, exit_ip: str | None, max_per_proxy: int | None = None) -> None:
async with self.lock: async with self.lock:
self.conn.execute( self.conn.execute(
"insert into checks(proxy_id,ts,ok,latency_ms,error,exit_ip) values(?,?,?,?,?,?)", "insert into checks(proxy_id,ts,ok,latency_ms,error,exit_ip) values(?,?,?,?,?,?)",
(proxy_id, now_ts(), 1 if ok else 0, latency_ms, error, exit_ip), (proxy_id, now_ts(), 1 if ok else 0, latency_ms, error, exit_ip),
) )
if max_per_proxy:
self.conn.execute(
"""
delete from checks
where proxy_id=? and id not in (
select id from checks where proxy_id=? order by ts desc, id desc limit ?
)
""",
(proxy_id, proxy_id, max(1, int(max_per_proxy))),
)
self.conn.commit() self.conn.commit()
async def add_event(self, proxy_id: str, old_status: str | None, new_status: str, message: str) -> None: async def add_event(self, proxy_id: str, old_status: str | None, new_status: str, message: str) -> None:
@@ -205,7 +217,7 @@ class Store:
self.conn.commit() self.conn.commit()
def recent_checks(self, proxy_id: str, limit: int = 50) -> list[dict[str, Any]]: def recent_checks(self, proxy_id: str, limit: int = 50) -> list[dict[str, Any]]:
rows = self.conn.execute("select * from checks where proxy_id=? order by ts desc limit ?", (proxy_id, limit)).fetchall() rows = self.conn.execute("select * from checks where proxy_id=? order by ts desc, id desc limit ?", (proxy_id, limit)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def recent_events(self, limit: int = 50) -> list[dict[str, Any]]: def recent_events(self, limit: int = 50) -> list[dict[str, Any]]:
@@ -216,6 +228,15 @@ class Store:
rows = self.conn.execute("select * from notification_logs order by ts desc limit ?", (limit,)).fetchall() rows = self.conn.execute("select * from notification_logs order by ts desc limit ?", (limit,)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
async def clear_logs(self) -> dict[str, int]:
async with self.lock:
removed = {}
for table in ("checks", "events", "notification_logs"):
cur = self.conn.execute(f"delete from {table}")
removed[table] = cur.rowcount if cur.rowcount is not None else 0
self.conn.commit()
return removed
@dataclasses.dataclass @dataclasses.dataclass
class RuntimeState: class RuntimeState:
@@ -538,6 +559,8 @@ class App:
out["fail_threshold"] = max(1, int(data.get("fail_threshold") or 3)) out["fail_threshold"] = max(1, int(data.get("fail_threshold") or 3))
if "recover_threshold" in data: if "recover_threshold" in data:
out["recover_threshold"] = max(1, int(data.get("recover_threshold") or 1)) out["recover_threshold"] = max(1, int(data.get("recover_threshold") or 1))
if "max_checks_per_proxy" in data:
out["max_checks_per_proxy"] = max(1, int(data.get("max_checks_per_proxy") or 500))
return out return out
async def update_settings(self, data: dict[str, Any]) -> dict[str, Any]: async def update_settings(self, data: dict[str, Any]) -> dict[str, Any]:
@@ -554,7 +577,7 @@ class App:
await self.save_config() await self.save_config()
for p in list(self.proxies): for p in list(self.proxies):
await self.restart_proxy_task(p["id"]) await self.restart_proxy_task(p["id"])
return {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold"]} return {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold", "max_checks_per_proxy"]}
async def upsert_proxy(self, data: dict[str, Any], proxy_id: str | None = None) -> dict[str, Any]: async def upsert_proxy(self, data: dict[str, Any], proxy_id: str | None = None) -> dict[str, Any]:
existing = self.get_proxy(proxy_id) if proxy_id else None existing = self.get_proxy(proxy_id) if proxy_id else None
@@ -600,7 +623,7 @@ class App:
proxy_id = proxy["id"] proxy_id = proxy["id"]
st = self.state_for(proxy_id) st = self.state_for(proxy_id)
ok, latency, err, exit_ip = await check_proxy(proxy) ok, latency, err, exit_ip = await check_proxy(proxy)
await self.store.add_check(proxy_id, ok, latency, err, exit_ip) await self.store.add_check(proxy_id, ok, latency, err, exit_ip, int(self.config.get("max_checks_per_proxy") or 500))
st.last_ts = now_ts(); st.last_ok = ok; st.latency_ms = latency; st.error = err; st.exit_ip = exit_ip st.last_ts = now_ts(); st.last_ok = ok; st.latency_ms = latency; st.error = err; st.exit_ip = exit_ip
fail_threshold = int(proxy.get("fail_threshold", self.config.get("fail_threshold", 3))) fail_threshold = int(proxy.get("fail_threshold", self.config.get("fail_threshold", 3)))
recover_threshold = int(proxy.get("recover_threshold", self.config.get("recover_threshold", 1))) recover_threshold = int(proxy.get("recover_threshold", self.config.get("recover_threshold", 1)))
@@ -650,7 +673,7 @@ class App:
items.append({"proxy": public_proxy(p), "state": dataclasses.asdict(st), "recent": self.store.recent_checks(sid, 20)}) items.append({"proxy": public_proxy(p), "state": dataclasses.asdict(st), "recent": self.store.recent_checks(sid, 20)})
return { return {
"ok": True, "now": now_ts(), "now_text": iso(), "ok": True, "now": now_ts(), "now_text": iso(),
"settings": {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold"]}, "settings": {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold", "max_checks_per_proxy"]},
"proxies": items, "proxies": items,
"notifications": self.config.get("notifications", {}), "notifications": self.config.get("notifications", {}),
"events": self.store.recent_events(50), "events": self.store.recent_events(50),
@@ -696,6 +719,8 @@ async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter
status, ctype, resp = json_response(app.status_payload()) status, ctype, resp = json_response(app.status_payload())
elif method == "PUT" and p == "/api/settings": elif method == "PUT" and p == "/api/settings":
status, ctype, resp = json_response({"ok": True, "settings": await app.update_settings(data)}) status, ctype, resp = json_response({"ok": True, "settings": await app.update_settings(data)})
elif method == "DELETE" and p == "/api/logs":
status, ctype, resp = json_response({"ok": True, "removed": await app.store.clear_logs()})
elif method == "POST" and p == "/api/proxies": elif method == "POST" and p == "/api/proxies":
status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201) status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201)
elif method == "PUT" and p.startswith("/api/proxies/"): elif method == "PUT" and p.startswith("/api/proxies/"):
+1
View File
@@ -5,6 +5,7 @@
"target_port": 443, "target_port": 443,
"fail_threshold": 3, "fail_threshold": 3,
"recover_threshold": 1, "recover_threshold": 1,
"max_checks_per_proxy": 500,
"db_path": "/app/data/monitor.db", "db_path": "/app/data/monitor.db",
"proxies": [ "proxies": [
{ {
+6 -5
View File
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
from __future__ import annotations
import asyncio
import importlib.util
import sys
import tempfile
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("socks5_app", ROOT / "app.py")
assert spec is not None and spec.loader is not None
app = importlib.util.module_from_spec(spec)
sys.modules["socks5_app"] = app
spec.loader.exec_module(app)
class LogRetentionTest(unittest.TestCase):
def test_check_records_are_pruned_per_proxy(self):
with tempfile.TemporaryDirectory() as td:
store = app.Store(Path(td) / "monitor.db")
async def run():
for i in range(5):
await store.add_check("node-a", True, i, None, f"203.0.113.{i}", max_per_proxy=2)
await store.add_check("node-b", True, 99, None, "198.51.100.1", max_per_proxy=2)
asyncio.run(run())
node_a = store.recent_checks("node-a", 10)
node_b = store.recent_checks("node-b", 10)
self.assertEqual(len(node_a), 2)
self.assertEqual([r["exit_ip"] for r in node_a], ["203.0.113.4", "203.0.113.3"])
self.assertEqual(len(node_b), 1)
def test_clear_logs_removes_checks_events_and_notification_logs(self):
with tempfile.TemporaryDirectory() as td:
store = app.Store(Path(td) / "monitor.db")
async def seed_and_clear():
await store.add_check("node-a", True, 1, None, "203.0.113.1")
await store.add_event("node-a", "unknown", "up", "ok")
await store.add_notification({"id": "ch-1", "name": "test"}, True, "title", "body", "ok")
removed = await store.clear_logs()
return removed
removed = asyncio.run(seed_and_clear())
self.assertEqual(removed["checks"], 1)
self.assertEqual(removed["events"], 1)
self.assertEqual(removed["notification_logs"], 1)
self.assertEqual(store.recent_checks("node-a", 10), [])
self.assertEqual(store.recent_events(10), [])
self.assertEqual(store.recent_notifications(10), [])
def test_settings_accept_max_checks_per_proxy(self):
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "config.json"
cfg.write_text('{"proxies": [], "notifications": {"channels": []}}', encoding="utf-8")
application = app.App(cfg)
settings = asyncio.run(application.update_settings({"max_checks_per_proxy": 7}))
self.assertEqual(settings["max_checks_per_proxy"], 7)
saved = app.load_json(cfg)
self.assertEqual(saved["max_checks_per_proxy"], 7)
if __name__ == "__main__":
unittest.main()