From d092f213a7002d5df872d0401d6779fe22c8fefe Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 9 Jul 2026 10:38:24 +0800 Subject: [PATCH] Fix hub system logs by resolving PM2 log paths from jlist and glob fallback. Co-authored-by: Cursor --- lib/hub/hub_system_logs_lib.py | 93 +++++++++++++++++++++++++++++-- tests/test_hub_system_logs_lib.py | 75 ++++++++++++++++++++----- 2 files changed, 150 insertions(+), 18 deletions(-) diff --git a/lib/hub/hub_system_logs_lib.py b/lib/hub/hub_system_logs_lib.py index 104818b..43aba88 100644 --- a/lib/hub/hub_system_logs_lib.py +++ b/lib/hub/hub_system_logs_lib.py @@ -1,7 +1,9 @@ """中控:读取 PM2 进程 stdout/stderr 日志(系统日志页).""" from __future__ import annotations +import json import os +import subprocess import time from pathlib import Path from typing import Any @@ -16,6 +18,10 @@ LOG_TARGETS: dict[str, dict[str, str]] = { DEFAULT_LINES = 200 MAX_LINES = 500 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: @@ -24,9 +30,86 @@ def pm2_logs_dir() -> Path: 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() - 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( @@ -72,7 +155,7 @@ def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]: raise KeyError(key) cfg = LOG_TARGETS[key] 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 { "ok": True, "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), "out_exists": out_path.is_file(), "err_exists": err_path.is_file(), - "out_path": str(out_path), - "err_path": str(err_path), + "out_path": str(out_path) if str(out_path) else "", + "err_path": str(err_path) if str(err_path) else "", "updated_at": int(time.time()), } diff --git a/tests/test_hub_system_logs_lib.py b/tests/test_hub_system_logs_lib.py index de91a2e..cead99e 100644 --- a/tests/test_hub_system_logs_lib.py +++ b/tests/test_hub_system_logs_lib.py @@ -4,16 +4,22 @@ from __future__ import annotations import tempfile import unittest 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 ( load_system_logs, - log_file_paths, + resolve_log_paths, system_logs_meta, tail_lines, ) 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): meta = system_logs_meta() self.assertTrue(meta["ok"]) @@ -27,21 +33,64 @@ class HubSystemLogsLibTest(unittest.TestCase): out = tail_lines(path, lines=3) 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): with self.assertRaises(KeyError): 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: logs_dir = Path(tmp) - out_path, err_path = log_file_paths("crypto_gate") - # monkeypatch by writing into expected structure under temp is hard; - # just verify shape with real call (files may be absent on dev machine). - payload = load_system_logs("gate", lines=50) - self.assertTrue(payload["ok"]) - self.assertEqual(payload["key"], "gate") - self.assertIn("out", payload) - self.assertIn("err", payload) - self.assertIsInstance(payload["out"], str) - self.assertIsInstance(payload["err"], str) - _ = out_path, err_path + out_file = logs_dir / "manual-trading-hub-out-6.log" + err_file = logs_dir / "manual-trading-hub-error-6.log" + out_file.write_text("hub stdout", encoding="utf-8") + err_file.write_text("hub stderr", encoding="utf-8") + payload = [ + { + "name": "manual-trading-hub", + "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): + 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"])