diff --git a/lib/hub/hub_system_logs_lib.py b/lib/hub/hub_system_logs_lib.py new file mode 100644 index 0000000..104818b --- /dev/null +++ b/lib/hub/hub_system_logs_lib.py @@ -0,0 +1,89 @@ +"""中控:读取 PM2 进程 stdout/stderr 日志(系统日志页).""" +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +LOG_TARGETS: dict[str, dict[str, str]] = { + "binance": {"label": "币安", "pm2_name": "crypto_binance"}, + "gate": {"label": "Gate", "pm2_name": "crypto_gate"}, + "okx": {"label": "OKX", "pm2_name": "crypto_okx"}, + "hub": {"label": "中控", "pm2_name": "manual-trading-hub"}, +} + +DEFAULT_LINES = 200 +MAX_LINES = 500 +DEFAULT_TAIL_BYTES = 400_000 + + +def pm2_logs_dir() -> Path: + raw = (os.getenv("PM2_HOME") or "").strip() + base = Path(raw) if raw else Path.home() / ".pm2" + return base / "logs" + + +def log_file_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" + + +def tail_lines( + path: Path, + lines: int = DEFAULT_LINES, + *, + max_bytes: int = DEFAULT_TAIL_BYTES, +) -> str: + if not path.is_file(): + return "" + try: + size = path.stat().st_size + with path.open("rb") as handle: + if size <= max_bytes: + data = handle.read() + else: + handle.seek(max(0, size - max_bytes)) + data = handle.read() + text = data.decode("utf-8", errors="replace") + parts = text.splitlines() + if len(parts) > lines: + parts = parts[-lines:] + return "\n".join(parts) + except OSError: + return "" + + +def system_logs_meta() -> dict[str, Any]: + return { + "ok": True, + "targets": [ + {"key": key, "label": cfg["label"], "pm2_name": cfg["pm2_name"]} + for key, cfg in LOG_TARGETS.items() + ], + "default_lines": DEFAULT_LINES, + "max_lines": MAX_LINES, + } + + +def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]: + key = (target or "").strip().lower() + if key not in LOG_TARGETS: + 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"]) + return { + "ok": True, + "key": key, + "label": cfg["label"], + "pm2_name": cfg["pm2_name"], + "lines": line_count, + "out": tail_lines(out_path, line_count), + "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), + "updated_at": int(time.time()), + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 46f6962..b7fa5be 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -95,6 +95,7 @@ from lib.hub.hub_strategy_lib import ( load_strategy_payload, strategy_meta_payload, ) +from lib.hub.hub_system_logs_lib import load_system_logs, system_logs_meta from lib.hub.hub_macro_calendar_lib import ( MACRO_EVENT_LABELS, MACRO_EVENT_TYPES, @@ -971,6 +972,7 @@ def root_redirect(): @app.get("/funds") @app.get("/ai") @app.get("/strategy") +@app.get("/logs") @app.get("/settings") def shell_pages(): return _shell_page() @@ -1081,6 +1083,7 @@ class SettingsDisplayBody(BaseModel): show_nav_ai: bool = True show_nav_calculator: bool = True show_nav_strategy: bool = True + show_nav_logs: bool = True class SupervisorSettingsBody(BaseModel): @@ -3261,6 +3264,19 @@ def api_strategy_meta(): return strategy_meta_payload() +@app.get("/api/system-logs/meta") +def api_system_logs_meta(): + return system_logs_meta() + + +@app.get("/api/system-logs/{target}") +def api_system_logs(target: str, lines: int = 200): + try: + return load_system_logs(target, lines=lines) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown log target"}, status_code=404) + + @app.get("/api/strategy/{exchange_key}") def api_strategy_detail(exchange_key: str): try: diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 19a9271..65d1e73 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -29,6 +29,7 @@ DEFAULT_DISPLAY = { "show_nav_ai": True, "show_nav_calculator": True, "show_nav_strategy": True, + "show_nav_logs": True, } DEFAULT_EXCHANGES = [ diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 6da49d1..dac8dfd 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -8321,3 +8321,101 @@ html[data-theme="light"] .hub-options-summary strong { color: #142232; } +/* ── 系统日志页 ── */ +.hub-logs-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.hub-logs-page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.hub-logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.hub-logs-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.hub-logs-tab { + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-2); + color: var(--text-soft); + cursor: pointer; + font-size: 0.88rem; +} +.hub-logs-tab.is-active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text); +} +#hub-logs-status.is-err { + color: var(--danger, #f87171); +} +.hub-logs-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} +.hub-logs-card { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 420px; + max-height: calc(100vh - 220px); + padding: 16px 18px 18px; +} +.hub-logs-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.hub-logs-card-title { + margin: 0; + font-size: 0.95rem; +} +.hub-logs-card-hint { + font-size: 0.72rem; + color: var(--muted, #8892b0); +} +.hub-logs-pre { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + margin: 0; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.74rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + background: var(--inset-surface, #0d1018); + border-radius: 8px; + border: 1px solid var(--border-soft); + color: #d8deef; +} +@media (max-width: 960px) { + .hub-logs-layout { + grid-template-columns: minmax(0, 1fr); + } + .hub-logs-card { + max-height: none; + min-height: 280px; + } +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 87fdcce..4e954b4 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -41,6 +41,10 @@ return displayPref("show_nav_strategy", true); } + function showNavLogsPref() { + return displayPref("show_nav_logs", true); + } + function syncNavVisibility(data) { const d = (data && data.display) || {}; const navFunds = document.getElementById("nav-funds"); @@ -50,6 +54,7 @@ const navAi = document.getElementById("nav-ai"); const navCalc = document.getElementById("nav-calculator"); const navStrategy = document.getElementById("nav-strategy"); + const navLogs = document.getElementById("nav-logs"); if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false); if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false); if (navPlan) navPlan.classList.toggle("nav-hidden", d.show_nav_plan === false); @@ -57,6 +62,7 @@ if (navAi) navAi.classList.toggle("nav-hidden", d.show_nav_ai === false); if (navCalc) navCalc.classList.toggle("nav-hidden", d.show_nav_calculator === false); if (navStrategy) navStrategy.classList.toggle("nav-hidden", d.show_nav_strategy === false); + if (navLogs) navLogs.classList.toggle("nav-hidden", d.show_nav_logs === false); } function pageNavAllowed(page) { @@ -67,6 +73,7 @@ if (page === "ai") return showNavAiPref(); if (page === "calculator") return showNavCalculatorPref(); if (page === "strategy") return showNavStrategyPref(); + if (page === "logs") return showNavLogsPref(); return true; } @@ -80,6 +87,7 @@ const aiCb = document.getElementById("pref-show-nav-ai"); const calcCb = document.getElementById("pref-show-nav-calculator"); const strategyCb = document.getElementById("pref-show-nav-strategy"); + const logsCb = document.getElementById("pref-show-nav-logs"); if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; if (fundsCb) fundsCb.checked = d.show_nav_funds !== false; if (dashCb) dashCb.checked = d.show_nav_dashboard !== false; @@ -88,6 +96,7 @@ if (aiCb) aiCb.checked = d.show_nav_ai !== false; if (calcCb) calcCb.checked = d.show_nav_calculator !== false; if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false; + if (logsCb) logsCb.checked = d.show_nav_logs !== false; syncNavVisibility(data); } @@ -1200,6 +1209,7 @@ if (p.includes("plan")) return "plan"; if (p.includes("calculator")) return "calculator"; if (p.includes("strategy")) return "strategy"; + if (p.includes("logs")) return "logs"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; return "monitor"; @@ -1213,6 +1223,7 @@ if (page === "plan") return "page-plan"; if (page === "calculator") return "page-calculator"; if (page === "strategy") return "page-strategy"; + if (page === "logs") return "page-logs"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; return "page-monitor"; @@ -1264,13 +1275,18 @@ } if (page === "funds" && window.hubFundsPage) { window.hubFundsPage.init(); + } else if (window.hubFundsPage && window.hubFundsPage.destroy) { + window.hubFundsPage.destroy(); } if (page === "strategy" && window.hubStrategyPage) { window.hubStrategyPage.init(); } else if (window.hubStrategyPage && window.hubStrategyPage.destroy) { window.hubStrategyPage.destroy(); - } else if (window.hubFundsPage && window.hubFundsPage.destroy) { - window.hubFundsPage.destroy(); + } + if (page === "logs" && window.hubLogsPage) { + window.hubLogsPage.init(); + } else if (window.hubLogsPage && window.hubLogsPage.destroy) { + window.hubLogsPage.destroy(); } if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); @@ -4667,6 +4683,7 @@ const aiCb = document.getElementById("pref-show-nav-ai"); const calcCb = document.getElementById("pref-show-nav-calculator"); const strategyCb = document.getElementById("pref-show-nav-strategy"); + const logsCb = document.getElementById("pref-show-nav-logs"); const supEnabled = document.getElementById("supervisor-enabled"); const supProg = document.getElementById("supervisor-wechat-program"); const supWebhook = document.getElementById("supervisor-wechat-webhook"); @@ -4687,6 +4704,7 @@ show_nav_ai: aiCb ? !!aiCb.checked : true, show_nav_calculator: calcCb ? !!calcCb.checked : true, show_nav_strategy: strategyCb ? !!strategyCb.checked : true, + show_nav_logs: logsCb ? !!logsCb.checked : true, }, supervisor: { enabled: supEnabled ? !!supEnabled.checked : true, diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 1c5f0af..0628f6f 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -57,6 +57,7 @@ 内照明心 数据看板 AI 教练 + 系统日志 系统设置 @@ -882,6 +883,39 @@ + +