This commit is contained in:
薇薇安 2026-01-19 12:59:41 +08:00
parent 82fd5e3f58
commit c348e3acbb
3 changed files with 202 additions and 0 deletions

View File

@ -2,6 +2,7 @@ import os
import re import re
import subprocess import subprocess
import json import json
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
@ -16,6 +17,41 @@ router = APIRouter(prefix="/api/system")
LOG_GROUPS = ("error", "warning", "info") LOG_GROUPS = ("error", "warning", "info")
# 后端服务启动时间(用于前端展示“运行多久/是否已重启”)
_BACKEND_STARTED_AT_MS: int = int(time.time() * 1000)
# 系统元信息存储(优先 Redis;用于记录重启时间等)
def _system_meta_prefix() -> str:
return (os.getenv("SYSTEM_META_PREFIX", "ats:system").strip() or "ats:system")
def _system_meta_key(name: str) -> str:
name = (name or "").strip()
return f"{_system_meta_prefix()}:{name}"
def _system_meta_read(name: str) -> Optional[Dict[str, Any]]:
client = _get_redis_client_for_logs()
if client is None:
return None
try:
raw = client.get(_system_meta_key(name))
if not raw:
return None
return json.loads(raw)
except Exception:
return None
def _system_meta_write(name: str, payload: Dict[str, Any], ttl_sec: int = 30 * 24 * 3600) -> None:
client = _get_redis_client_for_logs()
if client is None:
return
try:
client.setex(_system_meta_key(name), int(ttl_sec), json.dumps(payload, ensure_ascii=False))
except Exception:
return
# 避免 Redis 异常刷屏(前端可能自动刷新) # 避免 Redis 异常刷屏(前端可能自动刷新)
_last_logs_redis_err_ts: float = 0.0 _last_logs_redis_err_ts: float = 0.0
@ -714,6 +750,7 @@ async def trading_status(x_admin_token: Optional[str] = Header(default=None, ali
try: try:
raw, resolved_name, status_all = _status_with_fallback(program) raw, resolved_name, status_all = _status_with_fallback(program)
running, pid, state = _parse_supervisor_status(raw) running, pid, state = _parse_supervisor_status(raw)
meta = _system_meta_read("trading:last_restart") or {}
return { return {
"mode": "supervisor", "mode": "supervisor",
"program": program, "program": program,
@ -723,6 +760,7 @@ async def trading_status(x_admin_token: Optional[str] = Header(default=None, ali
"state": state, "state": state,
"raw": raw, "raw": raw,
"status_all": status_all, "status_all": status_all,
"meta": meta,
} }
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@ -791,9 +829,22 @@ async def trading_restart(x_admin_token: Optional[str] = Header(default=None, al
program = _get_program_name() program = _get_program_name()
try: try:
requested_at = _beijing_time_str()
requested_at_ms = int(time.time() * 1000)
out, resolved_name, status_all = _action_with_fallback("restart", program) out, resolved_name, status_all = _action_with_fallback("restart", program)
raw, resolved_name2, status_all2 = _status_with_fallback(resolved_name or program) raw, resolved_name2, status_all2 = _status_with_fallback(resolved_name or program)
running, pid, state = _parse_supervisor_status(raw) running, pid, state = _parse_supervisor_status(raw)
# 记录交易系统重启时间(用于前端展示)
_system_meta_write(
"trading:last_restart",
{
"requested_at": requested_at,
"requested_at_ms": requested_at_ms,
"pid": pid,
"program": resolved_name2 or resolved_name or program,
},
)
return { return {
"message": "交易系统已重启(supervisor)", "message": "交易系统已重启(supervisor)",
"output": out, "output": out,
@ -807,7 +858,89 @@ async def trading_restart(x_admin_token: Optional[str] = Header(default=None, al
"raw": raw, "raw": raw,
"status_all": status_all2 or status_all, "status_all": status_all2 or status_all,
}, },
"meta": {
"requested_at": requested_at,
"requested_at_ms": requested_at_ms,
},
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}") raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}")
@router.get("/backend/status")
async def backend_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
"""
查看后端服务状态(当前 uvicorn 进程)。
说明:
- pid 使用 os.getpid()(当前 FastAPI 进程)
- last_restart 从 Redis 读取(若可用)
"""
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
meta = _system_meta_read("backend:last_restart") or {}
return {
"running": True,
"pid": os.getpid(),
"started_at_ms": _BACKEND_STARTED_AT_MS,
"started_at": _beijing_time_str(),
"meta": meta,
}
@router.post("/backend/restart")
async def backend_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
"""
重启后端服务(uvicorn)。
实现方式:
- 后端启动脚本为 nohup uvicorn ... &
- 这里通过后台启动 backend/restart.sh 来完成:
1) grep 找到 uvicorn api.main:app 进程并 kill
2) 再执行 backend/start.sh 拉起新进程
注意:
- 为了让接口能先返回,这里会延迟 1s 再执行 restart.sh
"""
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
backend_dir = Path(__file__).parent.parent.parent # backend/
restart_script = backend_dir / "restart.sh"
if not restart_script.exists():
raise HTTPException(status_code=500, detail=f"找不到重启脚本: {restart_script}")
requested_at = _beijing_time_str()
requested_at_ms = int(time.time() * 1000)
cur_pid = os.getpid()
_system_meta_write(
"backend:last_restart",
{
"requested_at": requested_at,
"requested_at_ms": requested_at_ms,
"pid_before": cur_pid,
"script": str(restart_script),
},
)
# 后台执行:sleep 1 后再重启,保证当前请求可以返回
cmd = ["bash", "-lc", f"sleep 1; '{restart_script}'"]
try:
subprocess.Popen(
cmd,
cwd=str(backend_dir),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"启动重启脚本失败: {e}")
return {
"message": "已发起后端重启(1s 后执行)",
"pid_before": cur_pid,
"requested_at": requested_at,
"requested_at_ms": requested_at_ms,
"script": str(restart_script),
"note": "重启期间接口可能短暂不可用,页面可等待 3-5 秒后刷新状态。",
}

View File

@ -11,6 +11,7 @@ const ConfigPanel = () => {
const [feasibilityCheck, setFeasibilityCheck] = useState(null) const [feasibilityCheck, setFeasibilityCheck] = useState(null)
const [checkingFeasibility, setCheckingFeasibility] = useState(false) const [checkingFeasibility, setCheckingFeasibility] = useState(false)
const [systemStatus, setSystemStatus] = useState(null) const [systemStatus, setSystemStatus] = useState(null)
const [backendStatus, setBackendStatus] = useState(null)
const [systemBusy, setSystemBusy] = useState(false) const [systemBusy, setSystemBusy] = useState(false)
// 配置快照(用于整体分析/导出) // 配置快照(用于整体分析/导出)
@ -99,6 +100,15 @@ const ConfigPanel = () => {
} }
} }
const loadBackendStatus = async () => {
try {
const res = await api.getBackendStatus()
setBackendStatus(res)
} catch (error) {
// 静默失败:不影响配置页使用
}
}
const handleClearCache = async () => { const handleClearCache = async () => {
setSystemBusy(true) setSystemBusy(true)
setMessage('') setMessage('')
@ -156,13 +166,33 @@ const ConfigPanel = () => {
} }
} }
const handleRestartBackend = async () => {
if (!window.confirm('确定要重启后端服务吗?重启期间页面接口会短暂不可用(约 3-10 秒)。')) return
setSystemBusy(true)
setMessage('')
try {
const res = await api.restartBackend()
setMessage(res.message || '已发起后端重启')
// 给后端一点时间完成重启,再刷新状态
setTimeout(() => {
loadBackendStatus()
}, 4000)
} catch (error) {
setMessage('重启后端失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
useEffect(() => { useEffect(() => {
loadConfigs() loadConfigs()
checkFeasibility() checkFeasibility()
loadSystemStatus() loadSystemStatus()
loadBackendStatus()
const timer = setInterval(() => { const timer = setInterval(() => {
loadSystemStatus() loadSystemStatus()
loadBackendStatus()
}, 3000) }, 3000)
return () => clearInterval(timer) return () => clearInterval(timer)
@ -495,6 +525,7 @@ const ConfigPanel = () => {
</span> </span>
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null} {systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null} {systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
{systemStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {systemStatus.meta.requested_at}</span> : null}
</div> </div>
</div> </div>
<div className="system-actions"> <div className="system-actions">
@ -534,6 +565,22 @@ const ConfigPanel = () => {
> >
重启交易系统 重启交易系统
</button> </button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartBackend}
disabled={systemBusy}
title="通过 backend/restart.sh 重启后端(uvicorn)。重启期间接口会短暂不可用。"
>
重启后端服务
</button>
</div>
<div className="system-status" style={{ marginTop: '10px' }}>
<span className={`system-status-badge ${backendStatus?.running ? 'running' : 'stopped'}`}>
后端 {backendStatus?.running ? '运行中' : '未知'}
</span>
{backendStatus?.pid ? <span className="system-status-meta">PID: {backendStatus.pid}</span> : null}
{backendStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {backendStatus.meta.requested_at}</span> : null}
</div> </div>
<div className="system-hint"> <div className="system-hint">
建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。 建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。

View File

@ -298,6 +298,28 @@ export const api = {
return response.json(); return response.json();
}, },
// 后端控制(uvicorn)
getBackendStatus: async () => {
const response = await fetch(buildUrl('/api/system/backend/status'));
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取后端状态失败' }));
throw new Error(error.detail || '获取后端状态失败');
}
return response.json();
},
restartBackend: async () => {
const response = await fetch(buildUrl('/api/system/backend/restart'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '重启后端失败' }));
throw new Error(error.detail || '重启后端失败');
}
return response.json();
},
// 日志监控(Redis List) // 日志监控(Redis List)
getSystemLogs: async (params = {}) => { getSystemLogs: async (params = {}) => {
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();