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:
@@ -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()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user