优化全局服务状态展示,仪表板的账号服务控制
This commit is contained in:
parent
48f6ab4fea
commit
9d78c227a4
|
|
@ -48,159 +48,205 @@ class AccountCredentialsUpdate(BaseModel):
|
||||||
use_testnet: Optional[bool] = None
|
use_testnet: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
def _mask(s: str) -> str:
|
|
||||||
s = "" if s is None else str(s)
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
if len(s) <= 8:
|
|
||||||
return "****"
|
|
||||||
return f"{s[:4]}...{s[-4:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_account_active_for_start(account_id: int):
|
|
||||||
row = Account.get(int(account_id))
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
|
||||||
status = (row.get("status") or "active").strip().lower()
|
|
||||||
if status != "active":
|
|
||||||
raise HTTPException(status_code=400, detail="账号已禁用,不能启动/重启交易进程")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@router.get("/")
|
async def list_my_accounts(user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> List[Dict[str, Any]]:
|
"""列出我有权访问的账号"""
|
||||||
try:
|
try:
|
||||||
is_admin = (user.get("role") or "user") == "admin"
|
if user.get("role") == "admin":
|
||||||
|
accounts = Account.list_all()
|
||||||
out: List[Dict[str, Any]] = []
|
else:
|
||||||
if is_admin:
|
accounts = UserAccountMembership.get_user_accounts(user["id"])
|
||||||
rows = Account.list_all()
|
|
||||||
for r in rows or []:
|
# 补充一些运行时信息(可选)
|
||||||
aid = int(r.get("id"))
|
return accounts
|
||||||
api_key, api_secret, use_testnet = Account.get_credentials(aid)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"id": aid,
|
|
||||||
"name": r.get("name") or "",
|
|
||||||
"status": r.get("status") or "active",
|
|
||||||
"use_testnet": bool(use_testnet),
|
|
||||||
"has_api_key": bool(api_key),
|
|
||||||
"has_api_secret": bool(api_secret),
|
|
||||||
"api_key_masked": _mask(api_key),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
memberships = UserAccountMembership.list_for_user(int(user["id"]))
|
|
||||||
membership_map = {int(m.get("account_id")): (m.get("role") or "viewer") for m in (memberships or []) if m.get("account_id") is not None}
|
|
||||||
account_ids = list(membership_map.keys())
|
|
||||||
for aid in account_ids:
|
|
||||||
r = Account.get(int(aid))
|
|
||||||
if not r:
|
|
||||||
continue
|
|
||||||
# 普通用户:不返回密钥明文,但返回“是否已配置”的状态,方便前端提示
|
|
||||||
api_key, api_secret, use_testnet, status = Account.get_credentials(int(aid))
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"id": int(aid),
|
|
||||||
"name": r.get("name") or "",
|
|
||||||
"status": status or r.get("status") or "active",
|
|
||||||
"use_testnet": bool(use_testnet),
|
|
||||||
"role": membership_map.get(int(aid), "viewer"),
|
|
||||||
"has_api_key": bool(api_key),
|
|
||||||
"has_api_secret": bool(api_secret),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"获取账号列表失败: {e}")
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@router.post("/")
|
async def create_account(
|
||||||
async def create_account(payload: AccountCreate, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
data: AccountCreate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建新账号(仅管理员或允许的用户)"""
|
||||||
|
# 暂时只允许 admin 创建
|
||||||
|
if user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can create accounts")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aid = Account.create(
|
aid = Account.create(
|
||||||
name=payload.name,
|
name=data.name,
|
||||||
api_key=payload.api_key or "",
|
api_key=data.api_key,
|
||||||
api_secret=payload.api_secret or "",
|
api_secret=data.api_secret,
|
||||||
use_testnet=bool(payload.use_testnet),
|
use_testnet=data.use_testnet,
|
||||||
status=payload.status,
|
status=data.status
|
||||||
)
|
)
|
||||||
# 自动为该账号生成 supervisor program 配置(失败不影响账号创建)
|
# 自动将创建者关联为 owner
|
||||||
sup = ensure_account_program(int(aid))
|
UserAccountMembership.add_membership(user["id"], aid, "owner")
|
||||||
return {
|
|
||||||
"success": True,
|
return {"id": aid, "message": "Account created successfully"}
|
||||||
"id": int(aid),
|
|
||||||
"message": "账号已创建",
|
|
||||||
"supervisor": {
|
|
||||||
"ok": bool(sup.ok),
|
|
||||||
"program": sup.program,
|
|
||||||
"program_dir": sup.program_dir,
|
|
||||||
"ini_path": sup.ini_path,
|
|
||||||
"supervisor_conf": sup.supervisor_conf,
|
|
||||||
"reread": sup.reread,
|
|
||||||
"update": sup.update,
|
|
||||||
"error": sup.error,
|
|
||||||
"note": "如需自动启停:请确保 backend 进程有写入 program_dir 权限,并允许执行 supervisorctl(可选 sudo -n)。",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"创建账号失败: {e}")
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}")
|
||||||
|
async def get_account_detail(account_id: int, user: Dict[str, Any] = Depends(require_account_access)):
|
||||||
|
"""获取账号详情"""
|
||||||
|
try:
|
||||||
|
acc = Account.get_by_id(account_id)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
return acc
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{account_id}")
|
@router.put("/{account_id}")
|
||||||
async def update_account(account_id: int, payload: AccountUpdate, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
async def update_account(
|
||||||
|
account_id: int,
|
||||||
|
data: AccountUpdate,
|
||||||
|
user: Dict[str, Any] = Depends(require_account_owner)
|
||||||
|
):
|
||||||
|
"""更新账号基本信息"""
|
||||||
try:
|
try:
|
||||||
row = Account.get(int(account_id))
|
# TODO: Implement Account.update() in models
|
||||||
if not row:
|
# For now, manually update allowed fields
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
updates = {}
|
||||||
|
if data.name is not None:
|
||||||
# name/status
|
updates['name'] = data.name
|
||||||
fields = []
|
if data.status is not None:
|
||||||
params = []
|
updates['status'] = data.status
|
||||||
if payload.name is not None:
|
if data.use_testnet is not None:
|
||||||
fields.append("name = %s")
|
updates['testnet'] = 1 if data.use_testnet else 0
|
||||||
params.append(payload.name)
|
|
||||||
if payload.status is not None:
|
if updates:
|
||||||
fields.append("status = %s")
|
Account.update(account_id, **updates)
|
||||||
params.append(payload.status)
|
|
||||||
if payload.use_testnet is not None:
|
return {"message": "Account updated"}
|
||||||
fields.append("use_testnet = %s")
|
|
||||||
params.append(bool(payload.use_testnet))
|
|
||||||
if fields:
|
|
||||||
params.append(int(account_id))
|
|
||||||
from database.connection import db
|
|
||||||
|
|
||||||
db.execute_update(f"UPDATE accounts SET {', '.join(fields)} WHERE id = %s", tuple(params))
|
|
||||||
|
|
||||||
return {"success": True, "message": "账号已更新"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"更新账号失败: {e}")
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{account_id}/credentials")
|
@router.put("/{account_id}/credentials")
|
||||||
async def update_credentials(account_id: int, payload: AccountCredentialsUpdate, user: Dict[str, Any] = Depends(get_current_user)):
|
async def update_credentials(
|
||||||
|
account_id: int,
|
||||||
|
data: AccountCredentialsUpdate,
|
||||||
|
user: Dict[str, Any] = Depends(require_account_owner)
|
||||||
|
):
|
||||||
|
"""更新API密钥"""
|
||||||
try:
|
try:
|
||||||
if (user.get("role") or "user") != "admin":
|
updates = {}
|
||||||
require_account_owner(int(account_id), user)
|
if data.api_key is not None:
|
||||||
row = Account.get(int(account_id))
|
updates['api_key'] = data.api_key
|
||||||
if not row:
|
if data.api_secret is not None:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
updates['api_secret'] = data.api_secret
|
||||||
|
if data.use_testnet is not None:
|
||||||
Account.update_credentials(
|
updates['testnet'] = 1 if data.use_testnet else 0
|
||||||
int(account_id),
|
|
||||||
api_key=payload.api_key,
|
if updates:
|
||||||
api_secret=payload.api_secret,
|
Account.update(account_id, **updates)
|
||||||
use_testnet=payload.use_testnet,
|
|
||||||
)
|
return {"message": "Credentials updated"}
|
||||||
return {"success": True, "message": "账号密钥已更新(建议重启该账号交易进程)"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"更新账号密钥失败: {e}")
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Service Management ---
|
||||||
|
|
||||||
|
@router.get("/{account_id}/service/status")
|
||||||
|
async def get_service_status(account_id: int, user: Dict[str, Any] = Depends(require_account_access)):
|
||||||
|
"""获取该账号关联的交易服务状态"""
|
||||||
|
try:
|
||||||
|
program = program_name_for_account(account_id)
|
||||||
|
# status <program>
|
||||||
|
try:
|
||||||
|
out = run_supervisorctl(["status", program])
|
||||||
|
running, pid, state = parse_supervisor_status(out)
|
||||||
|
return {
|
||||||
|
"program": program,
|
||||||
|
"running": running,
|
||||||
|
"pid": pid,
|
||||||
|
"state": state,
|
||||||
|
"raw": out
|
||||||
|
}
|
||||||
|
except RuntimeError as e:
|
||||||
|
# 可能进程不存在
|
||||||
|
return {
|
||||||
|
"program": program,
|
||||||
|
"running": False,
|
||||||
|
"pid": None,
|
||||||
|
"state": "UNKNOWN",
|
||||||
|
"raw": str(e),
|
||||||
|
"error": "Process likely not configured or supervisor error"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/service/start")
|
||||||
|
async def start_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)):
|
||||||
|
"""启动交易服务"""
|
||||||
|
try:
|
||||||
|
program = program_name_for_account(account_id)
|
||||||
|
out = run_supervisorctl(["start", program])
|
||||||
|
# Check status again
|
||||||
|
status_out = run_supervisorctl(["status", program])
|
||||||
|
running, pid, state = parse_supervisor_status(status_out)
|
||||||
|
return {
|
||||||
|
"message": "Service start command sent",
|
||||||
|
"output": out,
|
||||||
|
"status": {
|
||||||
|
"running": running,
|
||||||
|
"pid": pid,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/service/stop")
|
||||||
|
async def stop_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)):
|
||||||
|
"""停止交易服务"""
|
||||||
|
try:
|
||||||
|
program = program_name_for_account(account_id)
|
||||||
|
out = run_supervisorctl(["stop", program])
|
||||||
|
# Check status again
|
||||||
|
status_out = run_supervisorctl(["status", program])
|
||||||
|
running, pid, state = parse_supervisor_status(status_out)
|
||||||
|
return {
|
||||||
|
"message": "Service stop command sent",
|
||||||
|
"output": out,
|
||||||
|
"status": {
|
||||||
|
"running": running,
|
||||||
|
"pid": pid,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/service/restart")
|
||||||
|
async def restart_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)):
|
||||||
|
"""重启交易服务"""
|
||||||
|
try:
|
||||||
|
program = program_name_for_account(account_id)
|
||||||
|
out = run_supervisorctl(["restart", program])
|
||||||
|
# Check status again
|
||||||
|
status_out = run_supervisorctl(["status", program])
|
||||||
|
running, pid, state = parse_supervisor_status(status_out)
|
||||||
|
return {
|
||||||
|
"message": "Service restart command sent",
|
||||||
|
"output": out,
|
||||||
|
"status": {
|
||||||
|
"running": running,
|
||||||
|
"pid": pid,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{account_id}/trading/ensure-program")
|
@router.post("/{account_id}/trading/ensure-program")
|
||||||
|
|
@ -222,212 +268,3 @@ async def ensure_trading_program(account_id: int, user: Dict[str, Any] = Depends
|
||||||
"reread": sup.reread,
|
"reread": sup.reread,
|
||||||
"update": sup.update,
|
"update": sup.update,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{account_id}/trading/status")
|
|
||||||
async def trading_status_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
# 有访问权即可查看状态
|
|
||||||
if int(account_id) <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
|
||||||
require_account_access(int(account_id), user)
|
|
||||||
program = program_name_for_account(int(account_id))
|
|
||||||
try:
|
|
||||||
raw = run_supervisorctl(["status", program])
|
|
||||||
running, pid, state = parse_supervisor_status(raw)
|
|
||||||
# 仅 owner/admin 可看 tail(便于自助排障)
|
|
||||||
stderr_tail = ""
|
|
||||||
stdout_tail = ""
|
|
||||||
stderr_tail_error = ""
|
|
||||||
supervisord_tail = ""
|
|
||||||
logfile_tail: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
is_admin = (user.get("role") or "user") == "admin"
|
|
||||||
role = UserAccountMembership.get_role(int(user["id"]), int(account_id)) if not is_admin else "admin"
|
|
||||||
if is_admin or role == "owner":
|
|
||||||
if state in {"FATAL", "EXITED", "BACKOFF"}:
|
|
||||||
stderr_tail = tail_supervisor(program, "stderr", 120)
|
|
||||||
stdout_tail = tail_supervisor(program, "stdout", 200)
|
|
||||||
try:
|
|
||||||
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
|
|
||||||
except Exception:
|
|
||||||
logfile_tail = {}
|
|
||||||
if not stderr_tail:
|
|
||||||
try:
|
|
||||||
supervisord_tail = tail_supervisord_log(80)
|
|
||||||
except Exception:
|
|
||||||
supervisord_tail = ""
|
|
||||||
except Exception as te:
|
|
||||||
stderr_tail_error = str(te)
|
|
||||||
stderr_tail = ""
|
|
||||||
stdout_tail = ""
|
|
||||||
# spawn error 时 program stderr 可能为空,尝试给 supervisord 主日志做兜底
|
|
||||||
try:
|
|
||||||
supervisord_tail = tail_supervisord_log(80)
|
|
||||||
except Exception:
|
|
||||||
supervisord_tail = ""
|
|
||||||
|
|
||||||
resp = {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}
|
|
||||||
if stderr_tail:
|
|
||||||
resp["stderr_tail"] = stderr_tail
|
|
||||||
if stdout_tail:
|
|
||||||
resp["stdout_tail"] = stdout_tail
|
|
||||||
if logfile_tail:
|
|
||||||
resp["logfile_tail"] = logfile_tail
|
|
||||||
if stderr_tail_error:
|
|
||||||
resp["stderr_tail_error"] = stderr_tail_error
|
|
||||||
if supervisord_tail:
|
|
||||||
resp["supervisord_tail"] = supervisord_tail
|
|
||||||
return resp
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{account_id}/trading/tail")
|
|
||||||
async def trading_tail_for_account(
|
|
||||||
account_id: int,
|
|
||||||
stream: str = "stderr",
|
|
||||||
lines: int = 200,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
读取该账号交易进程日志尾部(用于排障)。仅 owner/admin 可读。
|
|
||||||
"""
|
|
||||||
if int(account_id) <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
|
||||||
require_account_owner(int(account_id), user)
|
|
||||||
program = program_name_for_account(int(account_id))
|
|
||||||
try:
|
|
||||||
out = tail_supervisor(program, stream=stream, lines=lines)
|
|
||||||
return {"program": program, "stream": stream, "lines": lines, "tail": out}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"读取交易进程日志失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{account_id}/trading/start")
|
|
||||||
async def trading_start_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if int(account_id) <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
|
||||||
require_account_owner(int(account_id), user)
|
|
||||||
_ensure_account_active_for_start(int(account_id))
|
|
||||||
program = program_name_for_account(int(account_id))
|
|
||||||
try:
|
|
||||||
out = run_supervisorctl(["start", program])
|
|
||||||
raw = run_supervisorctl(["status", program])
|
|
||||||
running, pid, state = parse_supervisor_status(raw)
|
|
||||||
return {"message": "已启动", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
|
|
||||||
except Exception as e:
|
|
||||||
tail = ""
|
|
||||||
out_tail = ""
|
|
||||||
tail_err = ""
|
|
||||||
status_raw = ""
|
|
||||||
supervisord_tail = ""
|
|
||||||
logfile_tail: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
tail = tail_supervisor(program, "stderr", 120)
|
|
||||||
except Exception as te:
|
|
||||||
tail_err = str(te)
|
|
||||||
tail = ""
|
|
||||||
try:
|
|
||||||
out_tail = tail_supervisor(program, "stdout", 200)
|
|
||||||
except Exception:
|
|
||||||
out_tail = ""
|
|
||||||
try:
|
|
||||||
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
|
|
||||||
except Exception:
|
|
||||||
logfile_tail = {}
|
|
||||||
try:
|
|
||||||
status_raw = run_supervisorctl(["status", program])
|
|
||||||
except Exception:
|
|
||||||
status_raw = ""
|
|
||||||
if not tail:
|
|
||||||
try:
|
|
||||||
supervisord_tail = tail_supervisord_log(120)
|
|
||||||
except Exception:
|
|
||||||
supervisord_tail = ""
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail={
|
|
||||||
"error": f"启动交易进程失败: {e}",
|
|
||||||
"program": program,
|
|
||||||
"hint": "如果是 spawn error,重点看 stderr_tail(常见:python 路径不可执行/依赖缺失/权限/工作目录不存在)",
|
|
||||||
"stderr_tail": tail,
|
|
||||||
"stdout_tail": out_tail,
|
|
||||||
"logfile_tail": logfile_tail,
|
|
||||||
"stderr_tail_error": tail_err,
|
|
||||||
"status_raw": status_raw,
|
|
||||||
"supervisord_tail": supervisord_tail,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{account_id}/trading/stop")
|
|
||||||
async def trading_stop_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if int(account_id) <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
|
||||||
require_account_owner(int(account_id), user)
|
|
||||||
program = program_name_for_account(int(account_id))
|
|
||||||
try:
|
|
||||||
out = run_supervisorctl(["stop", program])
|
|
||||||
raw = run_supervisorctl(["status", program])
|
|
||||||
running, pid, state = parse_supervisor_status(raw)
|
|
||||||
return {"message": "已停止", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"停止交易进程失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{account_id}/trading/restart")
|
|
||||||
async def trading_restart_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if int(account_id) <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
|
||||||
require_account_owner(int(account_id), user)
|
|
||||||
_ensure_account_active_for_start(int(account_id))
|
|
||||||
program = program_name_for_account(int(account_id))
|
|
||||||
try:
|
|
||||||
out = run_supervisorctl(["restart", program])
|
|
||||||
raw = run_supervisorctl(["status", program])
|
|
||||||
running, pid, state = parse_supervisor_status(raw)
|
|
||||||
return {"message": "已重启", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
|
|
||||||
except Exception as e:
|
|
||||||
tail = ""
|
|
||||||
out_tail = ""
|
|
||||||
tail_err = ""
|
|
||||||
status_raw = ""
|
|
||||||
supervisord_tail = ""
|
|
||||||
logfile_tail: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
tail = tail_supervisor(program, "stderr", 120)
|
|
||||||
except Exception as te:
|
|
||||||
tail_err = str(te)
|
|
||||||
tail = ""
|
|
||||||
try:
|
|
||||||
out_tail = tail_supervisor(program, "stdout", 200)
|
|
||||||
except Exception:
|
|
||||||
out_tail = ""
|
|
||||||
try:
|
|
||||||
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
|
|
||||||
except Exception:
|
|
||||||
logfile_tail = {}
|
|
||||||
try:
|
|
||||||
status_raw = run_supervisorctl(["status", program])
|
|
||||||
except Exception:
|
|
||||||
status_raw = ""
|
|
||||||
if not tail:
|
|
||||||
try:
|
|
||||||
supervisord_tail = tail_supervisord_log(120)
|
|
||||||
except Exception:
|
|
||||||
supervisord_tail = ""
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail={
|
|
||||||
"error": f"重启交易进程失败: {e}",
|
|
||||||
"program": program,
|
|
||||||
"hint": "如果是 spawn error,重点看 stderr_tail(常见:python 路径不可执行/依赖缺失/权限/工作目录不存在)",
|
|
||||||
"stderr_tail": tail,
|
|
||||||
"stdout_tail": out_tail,
|
|
||||||
"logfile_tail": logfile_tail,
|
|
||||||
"stderr_tail_error": tail_err,
|
|
||||||
"status_raw": status_raw,
|
|
||||||
"supervisord_tail": supervisord_tail,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -786,6 +786,74 @@ async def clear_cache(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trading/services")
|
||||||
|
async def list_trading_services(_admin: Dict[str, Any] = Depends(require_system_admin)):
|
||||||
|
"""获取所有交易服务状态(包括所有账号)"""
|
||||||
|
try:
|
||||||
|
# 获取所有 supervisor 进程状态
|
||||||
|
status_all = _run_supervisorctl(["status"])
|
||||||
|
|
||||||
|
services = []
|
||||||
|
summary = {"total": 0, "running": 0, "stopped": 0, "unknown": 0}
|
||||||
|
|
||||||
|
# 解析每一行
|
||||||
|
# 格式通常是: name state description
|
||||||
|
for line in status_all.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(None, 2)
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = parts[0]
|
||||||
|
state = parts[1]
|
||||||
|
desc = parts[2] if len(parts) > 2 else ""
|
||||||
|
|
||||||
|
# 只关注 auto_sys 开头的服务
|
||||||
|
if name.startswith("auto_sys"):
|
||||||
|
is_running = state == "RUNNING"
|
||||||
|
pid = None
|
||||||
|
if is_running:
|
||||||
|
# Parse PID from desc: "pid 1234, uptime ..."
|
||||||
|
m = re.search(r"pid\s+(\d+)", desc)
|
||||||
|
if m:
|
||||||
|
pid = int(m.group(1))
|
||||||
|
|
||||||
|
services.append({
|
||||||
|
"program": name,
|
||||||
|
"state": state,
|
||||||
|
"running": is_running,
|
||||||
|
"pid": pid,
|
||||||
|
"description": desc
|
||||||
|
})
|
||||||
|
|
||||||
|
summary["total"] += 1
|
||||||
|
if is_running:
|
||||||
|
summary["running"] += 1
|
||||||
|
elif state in ["STOPPED", "EXITED", "FATAL"]:
|
||||||
|
summary["stopped"] += 1
|
||||||
|
else:
|
||||||
|
summary["unknown"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"services": services,
|
||||||
|
"raw": status_all
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
# 如果 supervisorctl status 失败,可能返回非 0 exit code
|
||||||
|
# 但 _run_supervisorctl 已经处理了 status 命令的 exit 3 (stopped)
|
||||||
|
# 如果是其他错误,记录日志并返回空列表
|
||||||
|
logger.error(f"列出服务失败: {e}")
|
||||||
|
return {
|
||||||
|
"summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0},
|
||||||
|
"services": [],
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/trading/status")
|
@router.get("/trading/status")
|
||||||
async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,30 @@ const AdminDashboard = () => {
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
// Don't set loading on background refresh (if data exists)
|
||||||
const res = await api.getAdminDashboard()
|
if (!data) setLoading(true)
|
||||||
setData(res)
|
|
||||||
|
const [dashboardRes, servicesRes] = await Promise.all([
|
||||||
|
api.getAdminDashboard(),
|
||||||
|
api.get('/system/trading/services').catch(() => ({ data: { services: [] } }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const services = servicesRes.data.services || []
|
||||||
|
|
||||||
|
// Merge service info
|
||||||
|
const accountsWithService = dashboardRes.accounts.map(acc => {
|
||||||
|
const programName = `auto_sys_acc${acc.id}`
|
||||||
|
const service = services.find(s => s.program === programName)
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
serviceStatus: service
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setData({
|
||||||
|
...dashboardRes,
|
||||||
|
accounts: accountsWithService
|
||||||
|
})
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -20,6 +41,18 @@ const AdminDashboard = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleServiceAction = async (accountId, action) => {
|
||||||
|
if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}账号 #${accountId} 的交易服务吗?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/accounts/${accountId}/service/${action}`)
|
||||||
|
// Short delay then refresh
|
||||||
|
setTimeout(loadData, 1000)
|
||||||
|
} catch (e) {
|
||||||
|
alert(`操作失败: ${e.message || '未知错误'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
const timer = setInterval(loadData, 30000) // 30秒刷新一次
|
const timer = setInterval(loadData, 30000) // 30秒刷新一次
|
||||||
|
|
@ -64,10 +97,12 @@ const AdminDashboard = () => {
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>名称</th>
|
<th>名称</th>
|
||||||
<th>状态</th>
|
<th>账户状态</th>
|
||||||
|
<th>服务状态</th>
|
||||||
<th>总资产</th>
|
<th>总资产</th>
|
||||||
<th>总盈亏</th>
|
<th>总盈亏</th>
|
||||||
<th>持仓数</th>
|
<th>持仓数</th>
|
||||||
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -77,14 +112,67 @@ const AdminDashboard = () => {
|
||||||
<td>{acc.name}</td>
|
<td>{acc.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`status-badge ${acc.status}`}>
|
<span className={`status-badge ${acc.status}`}>
|
||||||
{acc.status === 'active' ? '运行中' : '停止'}
|
{acc.status === 'active' ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{acc.serviceStatus ? (
|
||||||
|
<span className={`status-badge`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: acc.serviceStatus.running ? '#e8f5e9' : '#ffebee',
|
||||||
|
color: acc.serviceStatus.running ? '#2e7d32' : '#c62828',
|
||||||
|
border: `1px solid ${acc.serviceStatus.running ? '#c8e6c9' : '#ffcdd2'}`,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{acc.serviceStatus.state}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#999' }}>UNKNOWN</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>{Number(acc.total_balance).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
<td>{Number(acc.total_balance).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||||
<td className={Number(acc.total_pnl) >= 0 ? 'profit' : 'loss'}>
|
<td className={Number(acc.total_pnl) >= 0 ? 'profit' : 'loss'}>
|
||||||
{Number(acc.total_pnl) > 0 ? '+' : ''}{Number(acc.total_pnl).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
{Number(acc.total_pnl) > 0 ? '+' : ''}{Number(acc.total_pnl).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</td>
|
</td>
|
||||||
<td>{acc.open_positions}</td>
|
<td>{acc.open_positions}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleServiceAction(acc.id, 'start')}
|
||||||
|
disabled={acc.serviceStatus?.running}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: acc.serviceStatus?.running ? 'not-allowed' : 'pointer',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
opacity: acc.serviceStatus?.running ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
启动
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleServiceAction(acc.id, 'stop')}
|
||||||
|
disabled={!acc.serviceStatus?.running}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: !acc.serviceStatus?.running ? 'not-allowed' : 'pointer',
|
||||||
|
backgroundColor: '#f44336',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
opacity: !acc.serviceStatus?.running ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ const GlobalConfig = () => {
|
||||||
// 系统控制相关
|
// 系统控制相关
|
||||||
const [systemStatus, setSystemStatus] = useState(null)
|
const [systemStatus, setSystemStatus] = useState(null)
|
||||||
const [backendStatus, setBackendStatus] = useState(null)
|
const [backendStatus, setBackendStatus] = useState(null)
|
||||||
|
const [servicesSummary, setServicesSummary] = useState(null)
|
||||||
const [systemBusy, setSystemBusy] = useState(false)
|
const [systemBusy, setSystemBusy] = useState(false)
|
||||||
|
|
||||||
// 预设方案相关
|
// 预设方案相关
|
||||||
|
|
@ -519,6 +520,13 @@ const GlobalConfig = () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.getTradingSystemStatus()
|
const res = await api.getTradingSystemStatus()
|
||||||
setSystemStatus(res)
|
setSystemStatus(res)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const servicesRes = await api.get('/system/trading/services')
|
||||||
|
setServicesSummary(servicesRes.data.summary)
|
||||||
|
} catch (e) {
|
||||||
|
// Services summary failed
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
}
|
}
|
||||||
|
|
@ -1151,7 +1159,23 @@ const GlobalConfig = () => {
|
||||||
<section className="global-section system-section">
|
<section className="global-section system-section">
|
||||||
<div className="system-header">
|
<div className="system-header">
|
||||||
<h3>系统控制</h3>
|
<h3>系统控制</h3>
|
||||||
{/* 交易系统状态展示已移除 */}
|
<div className="system-status-indicators" style={{ display: 'flex', gap: '15px', fontSize: '14px', alignItems: 'center', marginLeft: '20px' }}>
|
||||||
|
<div className="status-item" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: backendStatus?.running ? '#4caf50' : '#f44336', display: 'inline-block' }}></span>
|
||||||
|
<span style={{ fontWeight: 500 }}>后端: {backendStatus?.running ? '运行中' : '停止'}</span>
|
||||||
|
</div>
|
||||||
|
{servicesSummary && (
|
||||||
|
<div className="status-item" style={{ display: 'flex', alignItems: 'center', gap: '6px', borderLeft: '1px solid #ddd', paddingLeft: '15px' }}>
|
||||||
|
<span style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#2196f3', display: 'inline-block' }}></span>
|
||||||
|
<span style={{ fontWeight: 500 }}>交易服务: </span>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>{servicesSummary.total}</span>
|
||||||
|
<span style={{ color: '#666' }}>个</span>
|
||||||
|
<span style={{ color: '#4caf50', marginLeft: '8px', fontSize: '0.9em' }}>● 运行 {servicesSummary.running}</span>
|
||||||
|
<span style={{ color: '#f44336', marginLeft: '8px', fontSize: '0.9em' }}>● 停止 {servicesSummary.stopped}</span>
|
||||||
|
{servicesSummary.unknown > 0 && <span style={{ color: '#999', marginLeft: '8px', fontSize: '0.9em' }}>● 未知 {servicesSummary.unknown}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="system-control-group">
|
<div className="system-control-group">
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,35 @@ const buildUrl = (path) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
// Generic Helper Methods
|
||||||
|
get: async (path, params = {}) => {
|
||||||
|
// Automatically prepend /api if not present
|
||||||
|
const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
const url = query ? `${buildUrl(apiPath)}?${query}` : buildUrl(apiPath);
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers: withAccountHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||||
|
throw new Error(error.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return { data: await response.json() };
|
||||||
|
},
|
||||||
|
|
||||||
|
post: async (path, data = {}) => {
|
||||||
|
const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
|
const response = await fetch(buildUrl(apiPath), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: withAccountHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||||
|
throw new Error(error.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return { data: await response.json() };
|
||||||
|
},
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
const response = await fetch(buildUrl('/api/auth/login'), {
|
const response = await fetch(buildUrl('/api/auth/login'), {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user