Add log retention settings and clear logs
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
- 检测间隔、超时、失败/恢复阈值可配置
|
||||
- 通知渠道:Bark、PushPlus、Autman、Webhook
|
||||
- SQLite 保存检测记录、事件和通知日志
|
||||
- 可配置单节点最多保留的监测记录数,并可一键清空日志
|
||||
- Docker / Docker Compose 部署
|
||||
|
||||
## 快速启动
|
||||
@@ -43,7 +44,8 @@ python3 app.py --host 0.0.0.0 --port 8787 --config config.json
|
||||
"interval_seconds": 300,
|
||||
"timeout_seconds": 8,
|
||||
"fail_threshold": 3,
|
||||
"recover_threshold": 1
|
||||
"recover_threshold": 1,
|
||||
"max_checks_per_proxy": 500
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,6 +98,7 @@ Autman 通道适配 `/m/push1` 消息推送插件,发送 JSON:
|
||||
```text
|
||||
GET /api/status
|
||||
PUT /api/settings
|
||||
DELETE /api/logs
|
||||
POST /api/check/<id>
|
||||
POST /api/notify/test
|
||||
GET /api/proxies
|
||||
|
||||
@@ -59,6 +59,7 @@ def default_config() -> dict[str, Any]:
|
||||
"target_port": 443,
|
||||
"fail_threshold": 3,
|
||||
"recover_threshold": 1,
|
||||
"max_checks_per_proxy": 500,
|
||||
"db_path": str(DEFAULT_DB),
|
||||
"proxies": [],
|
||||
"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_port"] = int(base.get("target_port") or 443)
|
||||
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", [])
|
||||
for proxy in base["proxies"]:
|
||||
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()
|
||||
|
||||
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:
|
||||
self.conn.execute(
|
||||
"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),
|
||||
)
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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]
|
||||
|
||||
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()
|
||||
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
|
||||
class RuntimeState:
|
||||
@@ -538,6 +559,8 @@ class App:
|
||||
out["fail_threshold"] = max(1, int(data.get("fail_threshold") or 3))
|
||||
if "recover_threshold" in data:
|
||||
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
|
||||
|
||||
async def update_settings(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -554,7 +577,7 @@ class App:
|
||||
await self.save_config()
|
||||
for p in list(self.proxies):
|
||||
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]:
|
||||
existing = self.get_proxy(proxy_id) if proxy_id else None
|
||||
@@ -600,7 +623,7 @@ class App:
|
||||
proxy_id = proxy["id"]
|
||||
st = self.state_for(proxy_id)
|
||||
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
|
||||
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)))
|
||||
@@ -650,7 +673,7 @@ class App:
|
||||
items.append({"proxy": public_proxy(p), "state": dataclasses.asdict(st), "recent": self.store.recent_checks(sid, 20)})
|
||||
return {
|
||||
"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,
|
||||
"notifications": self.config.get("notifications", {}),
|
||||
"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())
|
||||
elif method == "PUT" and p == "/api/settings":
|
||||
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":
|
||||
status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201)
|
||||
elif method == "PUT" and p.startswith("/api/proxies/"):
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"target_port": 443,
|
||||
"fail_threshold": 3,
|
||||
"recover_threshold": 1,
|
||||
"max_checks_per_proxy": 500,
|
||||
"db_path": "/app/data/monitor.db",
|
||||
"proxies": [
|
||||
{
|
||||
|
||||
+6
-5
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
Reference in New Issue
Block a user