Fix hub system logs by resolving PM2 log paths from jlist and glob fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-09 10:38:24 +08:00
parent 5214794f95
commit d092f213a7
2 changed files with 150 additions and 18 deletions
+88 -5
View File
@@ -1,7 +1,9 @@
"""中控:读取 PM2 进程 stdout/stderr 日志(系统日志页).""" """中控:读取 PM2 进程 stdout/stderr 日志(系统日志页)."""
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -16,6 +18,10 @@ LOG_TARGETS: dict[str, dict[str, str]] = {
DEFAULT_LINES = 200 DEFAULT_LINES = 200
MAX_LINES = 500 MAX_LINES = 500
DEFAULT_TAIL_BYTES = 400_000 DEFAULT_TAIL_BYTES = 400_000
_PATH_CACHE_TTL_SEC = 30
_path_cache: dict[str, tuple[Path, Path]] = {}
_path_cache_at = 0.0
def pm2_logs_dir() -> Path: def pm2_logs_dir() -> Path:
@@ -24,9 +30,86 @@ def pm2_logs_dir() -> Path:
return base / "logs" return base / "logs"
def log_file_paths(pm2_name: str) -> tuple[Path, Path]: def _pm2_jlist() -> list[dict[str, Any]]:
try:
proc = subprocess.run(
["pm2", "jlist"],
capture_output=True,
text=True,
timeout=8,
check=False,
)
if proc.returncode != 0:
return []
data = json.loads(proc.stdout or "[]")
return data if isinstance(data, list) else []
except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ValueError):
return []
def _newest_match(pattern: str, logs_dir: Path) -> Path | None:
matches = [p for p in logs_dir.glob(pattern) if p.is_file()]
if not matches:
return None
return max(matches, key=lambda p: p.stat().st_mtime)
def _glob_log_paths(pm2_name: str) -> tuple[Path, Path]:
logs_dir = pm2_logs_dir() logs_dir = pm2_logs_dir()
return logs_dir / f"{pm2_name}-out.log", logs_dir / f"{pm2_name}-error.log" out_path = Path()
err_path = Path()
if not logs_dir.is_dir():
return out_path, err_path
slugs = {pm2_name, pm2_name.replace("_", "-")}
for slug in slugs:
if not out_path.is_file():
hit = _newest_match(f"{slug}-out*.log", logs_dir)
if hit is not None:
out_path = hit
if not err_path.is_file():
hit = _newest_match(f"{slug}-error*.log", logs_dir)
if hit is not None:
err_path = hit
return out_path, err_path
def resolve_log_paths(pm2_name: str) -> tuple[Path, Path]:
global _path_cache_at
now = time.time()
if now - _path_cache_at > _PATH_CACHE_TTL_SEC:
_path_cache.clear()
_path_cache_at = now
cached = _path_cache.get(pm2_name)
if cached is not None:
return cached
out_path = Path()
err_path = Path()
for proc in _pm2_jlist():
if proc.get("name") != pm2_name:
continue
env = proc.get("pm2_env") or {}
raw_out = (env.get("pm_out_log_path") or "").strip()
raw_err = (env.get("pm_err_log_path") or "").strip()
if raw_out:
out_path = Path(raw_out)
if raw_err:
err_path = Path(raw_err)
break
glob_out, glob_err = _glob_log_paths(pm2_name)
if not out_path.is_file() and glob_out.is_file():
out_path = glob_out
if not err_path.is_file() and glob_err.is_file():
err_path = glob_err
result = (out_path, err_path)
_path_cache[pm2_name] = result
return result
def log_file_paths(pm2_name: str) -> tuple[Path, Path]:
return resolve_log_paths(pm2_name)
def tail_lines( def tail_lines(
@@ -72,7 +155,7 @@ def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]:
raise KeyError(key) raise KeyError(key)
cfg = LOG_TARGETS[key] cfg = LOG_TARGETS[key]
line_count = max(20, min(MAX_LINES, int(lines or DEFAULT_LINES))) line_count = max(20, min(MAX_LINES, int(lines or DEFAULT_LINES)))
out_path, err_path = log_file_paths(cfg["pm2_name"]) out_path, err_path = resolve_log_paths(cfg["pm2_name"])
return { return {
"ok": True, "ok": True,
"key": key, "key": key,
@@ -83,7 +166,7 @@ def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]:
"err": tail_lines(err_path, line_count), "err": tail_lines(err_path, line_count),
"out_exists": out_path.is_file(), "out_exists": out_path.is_file(),
"err_exists": err_path.is_file(), "err_exists": err_path.is_file(),
"out_path": str(out_path), "out_path": str(out_path) if str(out_path) else "",
"err_path": str(err_path), "err_path": str(err_path) if str(err_path) else "",
"updated_at": int(time.time()), "updated_at": int(time.time()),
} }
+62 -13
View File
@@ -4,16 +4,22 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from lib.hub import hub_system_logs_lib as logs_lib
from lib.hub.hub_system_logs_lib import ( from lib.hub.hub_system_logs_lib import (
load_system_logs, load_system_logs,
log_file_paths, resolve_log_paths,
system_logs_meta, system_logs_meta,
tail_lines, tail_lines,
) )
class HubSystemLogsLibTest(unittest.TestCase): class HubSystemLogsLibTest(unittest.TestCase):
def setUp(self):
logs_lib._path_cache.clear()
logs_lib._path_cache_at = 0.0
def test_system_logs_meta(self): def test_system_logs_meta(self):
meta = system_logs_meta() meta = system_logs_meta()
self.assertTrue(meta["ok"]) self.assertTrue(meta["ok"])
@@ -27,21 +33,64 @@ class HubSystemLogsLibTest(unittest.TestCase):
out = tail_lines(path, lines=3) out = tail_lines(path, lines=3)
self.assertEqual(out.splitlines(), ["line-8", "line-9", "line-10"]) self.assertEqual(out.splitlines(), ["line-8", "line-9", "line-10"])
def test_resolve_log_paths_from_pm2_jlist(self):
with tempfile.TemporaryDirectory() as tmp:
logs_dir = Path(tmp)
out_file = logs_dir / "crypto-binance-out-0.log"
err_file = logs_dir / "crypto-binance-error-0.log"
out_file.write_text("stdout line", encoding="utf-8")
err_file.write_text("stderr line", encoding="utf-8")
payload = [
{
"name": "crypto_binance",
"pm2_env": {
"pm_out_log_path": str(out_file),
"pm_err_log_path": str(err_file),
},
}
]
with patch.object(logs_lib, "_pm2_jlist", return_value=payload):
out_path, err_path = resolve_log_paths("crypto_binance")
self.assertEqual(out_path, out_file)
self.assertEqual(err_path, err_file)
def test_resolve_log_paths_glob_fallback(self):
with tempfile.TemporaryDirectory() as tmp:
logs_dir = Path(tmp)
out_file = logs_dir / "crypto-gate-out-1.log"
err_file = logs_dir / "crypto-gate-error-1.log"
out_file.write_text("gate out", encoding="utf-8")
err_file.write_text("gate err", encoding="utf-8")
with patch.object(logs_lib, "pm2_logs_dir", return_value=logs_dir):
with patch.object(logs_lib, "_pm2_jlist", return_value=[]):
out_path, err_path = resolve_log_paths("crypto_gate")
self.assertEqual(out_path, out_file)
self.assertEqual(err_path, err_file)
def test_load_system_logs_unknown(self): def test_load_system_logs_unknown(self):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
load_system_logs("unknown") load_system_logs("unknown")
def test_load_system_logs_missing_files(self): def test_load_system_logs_with_resolved_paths(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
logs_dir = Path(tmp) logs_dir = Path(tmp)
out_path, err_path = log_file_paths("crypto_gate") out_file = logs_dir / "manual-trading-hub-out-6.log"
# monkeypatch by writing into expected structure under temp is hard; err_file = logs_dir / "manual-trading-hub-error-6.log"
# just verify shape with real call (files may be absent on dev machine). out_file.write_text("hub stdout", encoding="utf-8")
payload = load_system_logs("gate", lines=50) err_file.write_text("hub stderr", encoding="utf-8")
self.assertTrue(payload["ok"]) payload = [
self.assertEqual(payload["key"], "gate") {
self.assertIn("out", payload) "name": "manual-trading-hub",
self.assertIn("err", payload) "pm2_env": {
self.assertIsInstance(payload["out"], str) "pm_out_log_path": str(out_file),
self.assertIsInstance(payload["err"], str) "pm_err_log_path": str(err_file),
_ = out_path, err_path },
}
]
with patch.object(logs_lib, "_pm2_jlist", return_value=payload):
data = load_system_logs("hub", lines=50)
self.assertTrue(data["ok"])
self.assertIn("hub stdout", data["out"])
self.assertIn("hub stderr", data["err"])
self.assertTrue(data["out_exists"])
self.assertTrue(data["err_exists"])