Add log retention settings and clear logs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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/"):
|
||||||
|
|||||||
@@ -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
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