Add Fleet control plane and split manage.sh deploy menu.

Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 10:46:40 +08:00
parent 200702d066
commit da5eb4c18c
44 changed files with 4623 additions and 15 deletions
+10
View File
@@ -0,0 +1,10 @@
# 比特骆驼中控(本地服务器)
# 一键部署会自动补全缺失项;已有非空值不会覆盖
CONTROL_AUTH_USERNAME=admin
CONTROL_AUTH_PASSWORD=admin123
CONTROL_AUTH_SECRET=change-me-control-secret-please
CONTROL_AUTH_TOKEN_VERSION=1
CONTROL_TOKEN_TTL_SEC=604800
CONTROL_POLL_INTERVAL_SEC=8
CONTROL_HTTP_TIMEOUT_SEC=12
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
+5
View File
@@ -1,6 +1,7 @@
# env / secrets # env / secrets
.env .env
.env.local .env.local
.env.control
*.pem *.pem
# python # python
@@ -18,6 +19,10 @@ backend/data/*.db
backend/data/*.sqlite backend/data/*.sqlite
backend/data/*.sqlite3 backend/data/*.sqlite3
!backend/data/.gitkeep !backend/data/.gitkeep
control/data/*
!control/data/.gitkeep
control/frontend/node_modules/
control/frontend/dist/
# frontend # frontend
frontend/node_modules/ frontend/node_modules/
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from .auth_routes import router as auth_router from .auth_routes import router as auth_router
from .backup_routes import router as backup_router from .backup_routes import router as backup_router
from .fleet import router as fleet_router
from .funds import router as funds_router from .funds import router as funds_router
from .market import router as market_router from .market import router as market_router
from .plan import router as plan_router from .plan import router as plan_router
@@ -20,3 +21,4 @@ router.include_router(stats_router)
router.include_router(funds_router) router.include_router(funds_router)
router.include_router(settings_router) router.include_router(settings_router)
router.include_router(backup_router) router.include_router(backup_router)
router.include_router(fleet_router)
+22
View File
@@ -11,9 +11,14 @@ from pydantic import BaseModel, Field
from ..config import Settings, get_settings from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials, upsert_env_file from ..credentials import get_credentials, update_credentials, upsert_env_file
from .auth import LoginRequest, LoginResponse, issue_token, require_user from .auth import LoginRequest, LoginResponse, issue_token, require_user
from .fleet import consume_login_ticket
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
class FleetExchangeRequest(BaseModel):
ticket: str = Field(min_length=8, max_length=256)
_login_hits: dict[str, list[float]] = defaultdict(list) _login_hits: dict[str, list[float]] = defaultdict(list)
@@ -45,6 +50,23 @@ def _rate_limit_login(ip: str, settings: Settings) -> None:
) )
@router.post("/fleet-exchange", response_model=LoginResponse)
async def fleet_exchange(
body: FleetExchangeRequest,
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
"""中控签发的一次性 ticket 兑换为普通登录会话(免密)。"""
username = consume_login_ticket(body.ticket)
token, ttl = issue_token(username, settings)
return LoginResponse(
token=token,
username=username,
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login( async def login(
body: LoginRequest, body: LoginRequest,
+273
View File
@@ -0,0 +1,273 @@
"""中控(Fleet)专用 APIX-Fleet-Token 鉴权,不开放资金/下单。"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import subprocess
import threading
import time
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from ..config import get_settings
from ..credentials import get_credentials
from ..models.db import get_db
from ..strategy import get_engine
from .auth import require_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/fleet", tags=["fleet"])
_SETTING_HASH = "fleet_api_token_hash"
_TICKET_TTL_SEC = 60
_tickets: dict[str, dict] = {}
_tickets_lock = threading.Lock()
_update_lock = threading.Lock()
_update_state: dict = {"running": False, "started_at_ms": 0, "last_error": ""}
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def fleet_token_configured(db=None) -> bool:
db = db or get_db()
h = (db.get_setting(_SETTING_HASH, "") or "").strip()
return bool(h)
def set_fleet_token(plain: str, db=None) -> None:
db = db or get_db()
plain = (plain or "").strip()
if not plain:
db.set_setting(_SETTING_HASH, "")
return
if len(plain) < 16:
raise ValueError("中控 API Token 至少 16 位")
db.set_setting(_SETTING_HASH, _hash_token(plain))
def clear_fleet_token(db=None) -> None:
set_fleet_token("", db)
def require_fleet_token(
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
) -> str:
db = get_db()
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
if not stored:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="策略机未配置中控 API Token",
)
provided = (x_fleet_token or "").strip()
if not provided or not hmac.compare_digest(stored, _hash_token(provided)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid fleet token",
)
return provided
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _purge_tickets() -> None:
now = time.time()
dead = [k for k, v in _tickets.items() if float(v.get("exp", 0)) < now]
for k in dead:
_tickets.pop(k, None)
def create_login_ticket(username: str) -> tuple[str, int]:
with _tickets_lock:
_purge_tickets()
ticket = secrets.token_urlsafe(32)
_tickets[ticket] = {"exp": time.time() + _TICKET_TTL_SEC, "u": username}
return ticket, _TICKET_TTL_SEC
def consume_login_ticket(ticket: str) -> str:
ticket = (ticket or "").strip()
if not ticket:
raise HTTPException(status_code=401, detail="invalid ticket")
with _tickets_lock:
_purge_tickets()
meta = _tickets.pop(ticket, None)
if not meta:
raise HTTPException(status_code=401, detail="ticket invalid or used")
if float(meta.get("exp", 0)) < time.time():
raise HTTPException(status_code=401, detail="ticket expired")
username = str(meta.get("u") or "").strip()
if not username:
raise HTTPException(status_code=401, detail="invalid ticket")
return username
class FleetTokenBody(BaseModel):
token: str = Field(default="", max_length=256)
@router.get("/meta")
async def fleet_meta(_user: Annotated[str, Depends(require_user)]) -> dict:
return {
"configured": fleet_token_configured(),
"hint": "在中控生成 Token 后粘贴到此保存;用于远程启停、更新与免密登录。",
}
@router.put("/token")
async def put_fleet_token(
body: FleetTokenBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
try:
set_fleet_token(body.token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "configured": fleet_token_configured()}
@router.delete("/token")
async def delete_fleet_token(_user: Annotated[str, Depends(require_user)]) -> dict:
clear_fleet_token()
return {"ok": True, "configured": False}
@router.get("/status")
async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
settings = get_settings()
try:
from ..exchange.runtime import load_runtime_settings
from ..strategy.session import get_session
rt = load_runtime_settings()
exchange_name = rt.exchange
sess = get_session()
snap = sess.snapshot() if sess else None
except Exception:
exchange_name = settings.exchange
snap = None
try:
st = get_engine().state()
except Exception:
st = {}
return {
"ok": True,
"mode": settings.mode,
"env_name": settings.env_name,
"exchange": exchange_name,
"sim": settings.is_sim,
"market_connected": bool(snap.connected) if snap else False,
"pair": snap.pair.to_dict() if snap and snap.pair else None,
"updated_at_ms": snap.updated_at_ms if snap else None,
"strategy": {
"running": st.get("running"),
"phase": st.get("phase"),
"rounds_done": st.get("rounds_done"),
"last_error": st.get("last_error"),
"group_id": st.get("group_id"),
},
"update": {
"running": bool(_update_state.get("running")),
"started_at_ms": _update_state.get("started_at_ms") or 0,
"last_error": _update_state.get("last_error") or "",
},
}
@router.post("/start")
async def fleet_start(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
return await get_engine().start()
@router.post("/pause")
async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
return await get_engine().pause()
@router.post("/issue-login")
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
username, _ = get_credentials()
ticket, ttl = create_login_ticket(username)
return {
"ok": True,
"ticket": ticket,
"expires_in": ttl,
"login_path": f"/fleet-login?ticket={ticket}",
}
def _run_update_job() -> None:
root = _repo_root()
script = root / "deploy" / "lib" / "update.sh"
if not script.is_file():
script = root / "deploy" / "pull_and_restart.sh"
try:
if os.name == "nt":
_update_state["last_error"] = "update script requires bash (Linux deploy host)"
logger.error("fleet update skipped: not a Linux deploy host")
return
if not script.is_file():
_update_state["last_error"] = f"update script missing: {script}"
logger.error("fleet update: %s", _update_state["last_error"])
return
logger.info("fleet update starting: %s", script)
proc = subprocess.run(
["bash", str(script)],
cwd=str(root),
capture_output=True,
text=True,
timeout=600,
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "")[-2000:]
_update_state["last_error"] = f"exit={proc.returncode} {err}"
logger.error("fleet update failed: %s", _update_state["last_error"])
else:
_update_state["last_error"] = ""
logger.info("fleet update finished ok")
except Exception as e:
_update_state["last_error"] = str(e)
logger.exception("fleet update exception")
finally:
_update_state["running"] = False
@router.post("/update")
async def fleet_update(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
"""接受更新请求:后台跑 deploy update(会 reload 本进程)。"""
with _update_lock:
if _update_state.get("running"):
return {
"ok": True,
"accepted": False,
"running": True,
"msg": "更新已在进行中",
}
_update_state["running"] = True
_update_state["started_at_ms"] = int(time.time() * 1000)
_update_state["last_error"] = ""
def _deferred() -> None:
time.sleep(0.8)
_run_update_job()
threading.Thread(target=_deferred, name="fleet-update", daemon=True).start()
return {
"ok": True,
"accepted": True,
"running": True,
"msg": "已接受更新,进程即将 reload,请稍后探活",
}
+1
View File
@@ -218,6 +218,7 @@ async def index_page():
@app.get("/stats") @app.get("/stats")
@app.get("/settings") @app.get("/settings")
@app.get("/login") @app.get("/login")
@app.get("/fleet-login")
async def spa_pages(full_path: str = ""): async def spa_pages(full_path: str = ""):
index = _DIST / "index.html" index = _DIST / "index.html"
if not index.exists(): if not index.exists():
+48
View File
@@ -0,0 +1,48 @@
"""Fleet token / ticket unit tests."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from app.api.fleet import (
clear_fleet_token,
consume_login_ticket,
create_login_ticket,
fleet_token_configured,
require_fleet_token,
set_fleet_token,
)
from app.models.db import Database, set_db
@pytest.fixture()
def db(tmp_path, monkeypatch):
monkeypatch.setenv("MODE", "SIM")
path = tmp_path / "t.db"
d = Database(path)
set_db(d)
yield d
d.close()
set_db(None)
def test_fleet_token_hash_and_auth(db):
assert not fleet_token_configured(db)
set_fleet_token("test-fleet-token-32chars-xxxx", db)
assert fleet_token_configured(db)
assert require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
with pytest.raises(HTTPException) as ei:
require_fleet_token(x_fleet_token="wrong-token-xxxxxxxxxx")
assert ei.value.status_code == 401
clear_fleet_token(db)
with pytest.raises(HTTPException):
require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
def test_login_ticket_once(db):
t, ttl = create_login_ticket("admin")
assert ttl >= 30
assert consume_login_ticket(t) == "admin"
with pytest.raises(HTTPException):
consume_login_ticket(t)
+1
View File
@@ -0,0 +1 @@
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .nodes import router as nodes_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(nodes_router)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import hmac
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..auth import issue_token, require_control_user
from ..config import ControlSettings, get_control_settings
from ..envfile import update_control_credentials
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginBody(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class ChangeCredentialsBody(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=6, max_length=128)
@router.post("/login")
async def login(
body: LoginBody,
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
user_ok = hmac.compare_digest(
body.username.encode("utf-8"),
settings.control_auth_username.encode("utf-8"),
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
)
if not (user_ok and pwd_ok):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings)
return {"token": token, "username": body.username, "expires_in": ttl}
@router.get("/me")
async def me(
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
return {
"username": username,
"poll_interval_sec": settings.control_poll_interval_sec,
}
@router.post("/change-credentials")
async def change_credentials(
body: ChangeCredentialsBody,
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
if not hmac.compare_digest(
body.current_password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
):
raise HTTPException(status_code=400, detail="当前密码不正确")
try:
update_control_credentials(
new_username=body.new_username,
new_password=body.new_password,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
settings2 = get_control_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return {
"token": token,
"username": body.new_username.strip(),
"expires_in": ttl,
}
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import secrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..auth import require_control_user
from ..config import get_control_settings
from ..crypto import seal
from ..db import get_control_db
from ..proxy import call_node, probe_health
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
def _public_node(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"base_url": row["base_url"],
"token_configured": bool((row.get("token_sealed") or "").strip()),
"created_at_ms": row["created_at_ms"],
"updated_at_ms": row["updated_at_ms"],
}
def _http_detail(data: Any) -> str:
if isinstance(data, dict):
d = data.get("detail", data)
return d if isinstance(d, str) else str(d)
return str(data)
class NodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
base_url: str = Field(min_length=8, max_length=256)
class NodeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
base_url: str | None = Field(default=None, min_length=8, max_length=256)
@router.get("/")
async def list_nodes(_user: Annotated[str, Depends(require_control_user)]) -> dict:
db = get_control_db()
nodes = [_public_node(n) for n in db.list_nodes()]
return {"nodes": nodes}
@router.post("/")
async def create_node(
body: NodeCreate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
try:
row = db.create_node(body.name, body.base_url)
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建失败: {e}") from e
return _public_node(row)
@router.get("/status/all")
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
db = get_control_db()
items = []
for node in db.list_nodes():
probe = await probe_health(node)
item = {**_public_node(node), **probe}
if probe.get("online") and node.get("token_sealed"):
code, data = await call_node(node, "GET", "/api/fleet/status")
if code == 200 and isinstance(data, dict):
item["fleet"] = data
items.append(item)
return {"nodes": items}
@router.post("/update-batch")
async def update_batch(
body: dict,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
ids = body.get("ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ids 不能为空")
db = get_control_db()
results = []
for nid in ids:
node = db.get_node(int(nid))
if not node:
results.append({"id": nid, "ok": False, "detail": "不存在"})
continue
code, data = await call_node(node, "POST", "/api/fleet/update")
results.append(
{
"id": nid,
"ok": code < 400,
"status": code,
"result": data,
}
)
return {"results": results}
@router.patch("/{node_id}")
async def update_node(
node_id: int,
body: NodeUpdate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
row = db.update_node(node_id, name=body.name, base_url=body.base_url)
if not row:
raise HTTPException(status_code=404, detail="节点不存在")
return _public_node(row)
@router.delete("/{node_id}")
async def delete_node(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
if not db.delete_node(node_id):
raise HTTPException(status_code=404, detail="节点不存在")
return {"ok": True}
@router.post("/{node_id}/generate-token")
async def generate_token(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""生成新 Token,加密存中控;明文仅返回一次,需粘贴到策略机设置。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
plain = secrets.token_urlsafe(32)
sealed = seal(plain, get_control_settings().control_auth_secret)
db.update_node(node_id, token_sealed=sealed)
return {
"ok": True,
"token": plain,
"msg": "请立即复制并到策略机「系统设置 → 登录账户 → 中控 API Token」保存",
}
@router.get("/{node_id}/status")
async def node_status(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
probe = await probe_health(node)
out = {**_public_node(node), **probe}
if probe.get("online") and node.get("token_sealed"):
code, data = await call_node(node, "GET", "/api/fleet/status")
if code == 200 and isinstance(data, dict):
out["fleet"] = data
return out
@router.post("/{node_id}/start")
async def node_start(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/start")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/pause")
async def node_pause(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/pause")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/update")
async def node_update(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/update")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/login-url")
async def node_login_url(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""用 Fleet Token 向策略机签发一次性登录票,返回可打开的 URL。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
path = ""
if isinstance(data, dict):
path = str(data.get("login_path") or "")
if not path:
raise HTTPException(status_code=502, detail="策略机未返回 login_path")
base = str(node["base_url"]).rstrip("/")
return {
"ok": True,
"url": f"{base}{path}",
"expires_in": data.get("expires_in") if isinstance(data, dict) else None,
}
+77
View File
@@ -0,0 +1,77 @@
"""中控登录 HMAC Token。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import ControlSettings, get_control_settings
_bearer = HTTPBearer(auto_error=False)
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def issue_token(username: str, settings: ControlSettings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.control_token_ttl_sec)
payload = {
"u": username,
"exp": exp,
"v": int(settings.control_auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
return f"{raw}.{sig}", settings.control_token_ttl_sec
def verify_token(token: str, settings: ControlSettings) -> str:
try:
raw, sig = token.rsplit(".", 1)
except ValueError as e:
raise HTTPException(status_code=401, detail="invalid token") from e
expect = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expect, sig):
raise HTTPException(status_code=401, detail="invalid token")
try:
payload = json.loads(_b64url_decode(raw))
except Exception as e:
raise HTTPException(status_code=401, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=401, detail="token expired")
if int(payload.get("v") or 0) != int(settings.control_auth_token_version):
raise HTTPException(status_code=401, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=401, detail="invalid token")
return username
def require_control_user(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> str:
if creds is None or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return verify_token(creds.credentials, settings)
+46
View File
@@ -0,0 +1,46 @@
"""中控配置。"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
def _control_root() -> Path:
# control/backend/app/config.py -> control/
return Path(__file__).resolve().parents[2]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
class ControlSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(_repo_root() / ".env.control"),
env_file_encoding="utf-8",
extra="ignore",
)
control_auth_username: str = "admin"
control_auth_password: str = "admin123"
control_auth_secret: str = "change-me-control-secret-please"
control_auth_token_version: int = 1
control_token_ttl_sec: int = 7 * 24 * 3600
control_db_path: str = ""
control_poll_interval_sec: int = 8
control_http_timeout_sec: float = 12.0
control_port: int = 5160
@property
def db_path(self) -> Path:
if self.control_db_path.strip():
return Path(self.control_db_path)
return _control_root() / "data" / "control.db"
@lru_cache
def get_control_settings() -> ControlSettings:
return ControlSettings()
+42
View File
@@ -0,0 +1,42 @@
"""简易密封:无需 cryptography 依赖。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
def _keystream(key: bytes, n: int) -> bytes:
out = bytearray()
counter = 0
while len(out) < n:
block = hashlib.sha256(key + counter.to_bytes(8, "big")).digest()
out.extend(block)
counter += 1
return bytes(out[:n])
def seal(plaintext: str, secret: str) -> str:
raw = plaintext.encode("utf-8")
key = hashlib.sha256(secret.encode("utf-8")).digest()
iv = os.urandom(16)
stream = _keystream(key + iv, len(raw))
cipher = bytes(a ^ b for a, b in zip(raw, stream))
mac = hmac.new(key, iv + cipher, hashlib.sha256).digest()
return base64.urlsafe_b64encode(iv + mac + cipher).decode("ascii")
def unseal(blob: str, secret: str) -> str:
data = base64.urlsafe_b64decode(blob.encode("ascii"))
if len(data) < 16 + 32:
raise ValueError("invalid sealed blob")
iv, mac, cipher = data[:16], data[16:48], data[48:]
key = hashlib.sha256(secret.encode("utf-8")).digest()
expect = hmac.new(key, iv + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(expect, mac):
raise ValueError("sealed blob mac mismatch")
stream = _keystream(key + iv, len(cipher))
raw = bytes(a ^ b for a, b in zip(cipher, stream))
return raw.decode("utf-8")
+119
View File
@@ -0,0 +1,119 @@
"""中控 SQLite。"""
from __future__ import annotations
from pathlib import Path
from threading import Lock
from typing import Any
from .config import get_control_settings
_SCHEMA = """
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL UNIQUE,
token_sealed TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
_db: "ControlDB | None" = None
_lock = Lock()
def set_control_db(db: "ControlDB | None") -> None:
global _db
_db = db
def get_control_db() -> "ControlDB":
if _db is None:
raise RuntimeError("control db not initialized")
return _db
class ControlDB:
def __init__(self, path: Path | None = None) -> None:
import sqlite3
import time
settings = get_control_settings()
self.path = path or settings.db_path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._lock = Lock()
# touch
_ = time.time()
def close(self) -> None:
self._conn.close()
def list_nodes(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT id, name, base_url, token_sealed, created_at_ms, updated_at_ms FROM nodes ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def get_node(self, node_id: int) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute(
"SELECT id, name, base_url, token_sealed, created_at_ms, updated_at_ms FROM nodes WHERE id=?",
(node_id,),
).fetchone()
return dict(row) if row else None
def create_node(self, name: str, base_url: str) -> dict[str, Any]:
import time
now = int(time.time() * 1000)
name = name.strip()
base_url = base_url.strip().rstrip("/")
with self._lock:
cur = self._conn.execute(
"INSERT INTO nodes(name, base_url, token_sealed, created_at_ms, updated_at_ms) VALUES (?,?,?,?,?)",
(name, base_url, "", now, now),
)
self._conn.commit()
nid = int(cur.lastrowid)
return self.get_node(nid) # type: ignore[return-value]
def update_node(
self,
node_id: int,
*,
name: str | None = None,
base_url: str | None = None,
token_sealed: str | None = None,
) -> dict[str, Any] | None:
import time
node = self.get_node(node_id)
if not node:
return None
now = int(time.time() * 1000)
new_name = name.strip() if name is not None else node["name"]
new_url = base_url.strip().rstrip("/") if base_url is not None else node["base_url"]
new_tok = token_sealed if token_sealed is not None else node["token_sealed"]
with self._lock:
self._conn.execute(
"UPDATE nodes SET name=?, base_url=?, token_sealed=?, updated_at_ms=? WHERE id=?",
(new_name, new_url, new_tok, now, node_id),
)
self._conn.commit()
return self.get_node(node_id)
def delete_node(self, node_id: int) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
self._conn.commit()
return cur.rowcount > 0
+121
View File
@@ -0,0 +1,121 @@
"""读写仓库根 .env.control(仅补缺,不覆盖已有值)。"""
from __future__ import annotations
import re
from pathlib import Path
from .config import _repo_root, get_control_settings
def env_control_path() -> Path:
return _repo_root() / ".env.control"
def _read_text(path: Path) -> str:
if not path.is_file():
return ""
return path.read_text(encoding="utf-8")
def _write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if text and not text.endswith("\n"):
text += "\n"
path.write_text(text, encoding="utf-8")
try:
path.chmod(0o600)
except Exception:
pass
def get_env_value(key: str, text: str | None = None) -> str | None:
raw = text if text is not None else _read_text(env_control_path())
m = re.search(rf"(?m)^{re.escape(key)}=(.*)$", raw)
if not m:
return None
val = m.group(1).strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
val = val[1:-1]
return val
def upsert_env_control(key: str, value: str, *, overwrite: bool = True) -> Path:
"""写入/更新单个键。overwrite=False 时若已有非空值则跳过。"""
if "\n" in value or "\r" in value:
raise ValueError(f"{key} 值不能包含换行")
path = env_control_path()
text = _read_text(path)
existing = get_env_value(key, text)
if not overwrite and existing is not None and existing.strip() != "":
return path
safe = value.replace("\\", "\\\\").replace('"', '\\"')
line = f'{key}="{safe}"'
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
if pattern.search(text):
if not overwrite:
return path
text = pattern.sub(line, text)
else:
if text and not text.endswith("\n"):
text += "\n"
text += line + "\n"
_write_text(path, text)
return path
# 一键部署默认项:仅在缺失或为空时写入
_DEPLOY_DEFAULTS: dict[str, str] = {
"CONTROL_AUTH_USERNAME": "admin",
"CONTROL_AUTH_PASSWORD": "admin123",
"CONTROL_AUTH_SECRET": "change-me-control-secret-please",
"CONTROL_TOKEN_TTL_SEC": "604800",
"CONTROL_POLL_INTERVAL_SEC": "8",
"CONTROL_HTTP_TIMEOUT_SEC": "12",
"CONTROL_AUTH_TOKEN_VERSION": "1",
}
def ensure_env_control_defaults() -> dict[str, bool]:
"""
确保 .env.control 存在且关键键有值。
已有非空值绝不覆盖。返回 {key: written?}。
"""
written: dict[str, bool] = {}
path = env_control_path()
before = _read_text(path)
for key, default in _DEPLOY_DEFAULTS.items():
old = get_env_value(key, before)
if old is not None and old.strip() != "":
written[key] = False
continue
upsert_env_control(key, default, overwrite=False)
# 若文件原先无该键,before 里也没有;重新读确认
after = get_env_value(key)
written[key] = after == default or (old is None or old.strip() == "")
get_control_settings.cache_clear()
return written
def update_control_credentials(
*,
new_username: str,
new_password: str,
bump_token_version: bool = True,
) -> None:
user = new_username.strip()
pwd = new_password
if not user or not pwd:
raise ValueError("用户名和密码不能为空")
if len(pwd) < 6:
raise ValueError("密码至少 6 位")
upsert_env_control("CONTROL_AUTH_USERNAME", user, overwrite=True)
upsert_env_control("CONTROL_AUTH_PASSWORD", pwd, overwrite=True)
if bump_token_version:
cur = get_env_value("CONTROL_AUTH_TOKEN_VERSION") or "1"
try:
ver = int(cur) + 1
except ValueError:
ver = 2
upsert_env_control("CONTROL_AUTH_TOKEN_VERSION", str(ver), overwrite=True)
get_control_settings.cache_clear()
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .api import router as api_router
from .config import get_control_settings
from .db import ControlDB, set_control_db
def resolve_frontend_dist() -> Path:
# control/backend/app -> control/frontend/dist
return Path(__file__).resolve().parents[2] / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
from .envfile import ensure_env_control_defaults
# 首次启动补全 .env.control 缺项(已有值不覆盖)
ensure_env_control_defaults()
db = ControlDB()
set_control_db(db)
yield
db.close()
set_control_db(None)
app = FastAPI(
title="比特骆驼中控",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health() -> dict:
s = get_control_settings()
return {"ok": True, "app": "control", "poll_interval_sec": s.control_poll_interval_sec}
_DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
@app.get("/")
@app.get("/monitor")
@app.get("/settings")
@app.get("/login")
async def spa(full_path: str = ""):
index = _DIST / "index.html"
if index.exists():
return FileResponse(index)
if Path(__file__).name:
return {
"ok": True,
"msg": "control frontend not built; cd control/frontend && npm ci && npm run build",
"health": "/health",
}
raise HTTPException(status_code=404, detail="frontend missing")
+73
View File
@@ -0,0 +1,73 @@
"""代理调用策略机。"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from .config import get_control_settings
from .crypto import unseal
logger = logging.getLogger(__name__)
def node_token_plain(node: dict[str, Any]) -> str | None:
sealed = (node.get("token_sealed") or "").strip()
if not sealed:
return None
secret = get_control_settings().control_auth_secret
try:
return unseal(sealed, secret)
except Exception:
logger.exception("unseal fleet token failed node=%s", node.get("id"))
return None
async def call_node(
node: dict[str, Any],
method: str,
path: str,
*,
require_token: bool = True,
json_body: dict | None = None,
) -> tuple[int, Any]:
settings = get_control_settings()
base = str(node["base_url"]).rstrip("/")
headers: dict[str, str] = {}
if require_token:
tok = node_token_plain(node)
if not tok:
return 400, {"detail": "该机尚未生成/保存中控 Token"}
headers["X-Fleet-Token"] = tok
url = f"{base}{path}"
timeout = settings.control_http_timeout_sec
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
res = await client.request(method, url, headers=headers, json=json_body)
try:
data = res.json()
except Exception:
data = {"detail": res.text[:500]}
return res.status_code, data
except Exception as e:
return 502, {"detail": f"连接失败: {e}"}
async def probe_health(node: dict[str, Any]) -> dict[str, Any]:
code, data = await call_node(node, "GET", "/health", require_token=False)
if code == 200 and isinstance(data, dict):
return {"online": True, "health": data, "error": None}
# fallback fleet status if health blocked
code2, data2 = await call_node(node, "GET", "/api/fleet/status", require_token=True)
if code2 == 200 and isinstance(data2, dict):
return {"online": True, "health": data2, "error": None}
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or "")
return {
"online": False,
"health": None,
"error": detail or f"HTTP {code}",
}
View File
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
apps: [
{
name: 'eth-hedge-control',
cwd: '/opt/eth_hedge_sim/control/backend',
script: '/opt/eth_hedge_sim/.venv/bin/uvicorn',
args: 'app.main:app --host 0.0.0.0 --port 5160',
interpreter: 'none',
env: {
TZ: 'Asia/Shanghai',
},
// 凭据读仓库根 .env.control(勿写进本文件)
},
],
};
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# 中控一键更新:git pull + 构建前端 + pm2 reload eth-hedge-control
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# control/deploy -> repo root
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${REPO_ROOT}"
echo "[control] pull @ ${REPO_ROOT}"
git fetch origin
git pull --ff-only origin main || git pull --ff-only
if [[ ! -d "${REPO_ROOT}/.venv" ]]; then
python3 -m venv "${REPO_ROOT}/.venv"
fi
# shellcheck disable=SC1091
source "${REPO_ROOT}/.venv/bin/activate"
pip install -q -r "${REPO_ROOT}/requirements.txt"
echo "[control] ensure .env.control defaults (never overwrite existing values)"
cd "${REPO_ROOT}/control/backend"
python - <<'PY'
from app.envfile import ensure_env_control_defaults
wrote = ensure_env_control_defaults()
for k, v in wrote.items():
print(f" {k}: {'filled' if v else 'kept'}")
PY
cd "${REPO_ROOT}"
echo "[control] build frontend"
cd "${REPO_ROOT}/control/frontend"
if [[ -f package-lock.json ]]; then
npm ci
else
npm install
fi
npm run build
echo "[control] pm2 reload"
cd "${REPO_ROOT}"
pm2 startOrReload "${REPO_ROOT}/control/deploy/ecosystem.config.cjs" --update-env
pm2 save
echo "[control] health"
sleep 2
curl -fsS http://127.0.0.1:5160/health || true
echo
echo "[control] done"
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>比特骆驼中控</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1920
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "bitcamel-control-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { type ReactNode } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { clearSession, getToken, getUsername } from "./api";
import LoginPage from "./pages/Login";
import MonitorPage from "./pages/Monitor";
import SettingsPage from "./pages/Settings";
function Shell({ children }: { children: ReactNode }) {
const user = getUsername();
return (
<div className="shell">
<header className="header">
<div className="brand"></div>
<nav className="nav">
<NavLink to="/monitor" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/settings" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
</nav>
<div className="header-right">
<span className="meta">{user}</span>
<button
type="button"
className="btn ghost"
onClick={() => {
clearSession();
window.location.href = "/login";
}}
>
退
</button>
</div>
</header>
<main className="main">{children}</main>
</div>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <Shell>{children}</Shell>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/monitor"
element={
<RequireAuth>
<MonitorPage />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route path="/" element={<Navigate to="/monitor" replace />} />
<Route path="*" element={<Navigate to="/monitor" replace />} />
</Routes>
);
}
+75
View File
@@ -0,0 +1,75 @@
const TOKEN_KEY = "control_token";
const USER_KEY = "control_user";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getUsername(): string | null {
return localStorage.getItem(USER_KEY);
}
export function setSession(token: string, username: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, username);
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = new Headers(options.headers || {});
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
clearSession();
throw new Error("unauthorized");
}
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { detail: text };
}
if (!res.ok) {
const detail =
typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail)
: res.statusText;
throw new Error(detail || `HTTP ${res.status}`);
}
return data as T;
}
export async function login(username: string, password: string) {
const res = await apiFetch<{ token: string; username: string }>(
"/api/auth/login",
{
method: "POST",
body: JSON.stringify({ username, password }),
},
);
setSession(res.token, res.username);
return res;
}
export type NodeCard = {
id: number;
name: string;
base_url: string;
token_configured: boolean;
online?: boolean;
health?: Record<string, unknown> | null;
fleet?: Record<string, unknown> | null;
error?: string | null;
};
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+51
View File
@@ -0,0 +1,51 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api";
export default function LoginPage() {
const nav = useNavigate();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setErr("");
setLoading(true);
try {
await login(username.trim(), password);
nav("/monitor", { replace: true });
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<h1></h1>
<p className="meta"> · admin / admin123</p>
{err ? <div className="err">{err}</div> : null}
<label>
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<button className="btn" type="submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</form>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useState } from "react";
import { apiFetch, type NodeCard } from "../api";
function pickStrategy(n: NodeCard) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const health = (n.health || {}) as Record<string, unknown>;
const strat =
(fleet.strategy as Record<string, unknown> | undefined) ||
(health.strategy as Record<string, unknown> | undefined) ||
{};
return {
mode: String(fleet.mode || health.mode || "-"),
running: strat.running,
phase: String(strat.phase ?? "-"),
rounds: strat.rounds_done,
market: fleet.market_connected ?? health.market_connected,
exchange: String(fleet.exchange || health.exchange || "-"),
};
}
export default function MonitorPage() {
const [nodes, setNodes] = useState<NodeCard[]>([]);
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Record<number, string>>({});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pollSec, setPollSec] = useState(8);
const refresh = useCallback(async () => {
try {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/status/all");
setNodes(r.nodes || []);
setErr("");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}, []);
useEffect(() => {
apiFetch<{ poll_interval_sec?: number }>("/api/auth/me")
.then((m) => {
if (m.poll_interval_sec) setPollSec(m.poll_interval_sec);
})
.catch(() => undefined);
void refresh();
const id = window.setInterval(() => void refresh(), pollSec * 1000);
return () => window.clearInterval(id);
}, [refresh, pollSec]);
async function act(id: number, action: "start" | "pause" | "update" | "login") {
setBusy((b) => ({ ...b, [id]: action }));
setErr("");
try {
if (action === "login") {
const r = await apiFetch<{ url: string }>(`/api/nodes/${id}/login-url`, {
method: "POST",
});
window.open(r.url, "_blank", "noopener,noreferrer");
} else {
await apiFetch(`/api/nodes/${id}/${action === "pause" ? "pause" : action}`, {
method: "POST",
});
await refresh();
}
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setBusy((b) => {
const n = { ...b };
delete n[id];
return n;
});
}
}
async function batchUpdate() {
const ids = [...selected];
if (!ids.length) return;
setErr("");
try {
await apiFetch("/api/nodes/update-batch", {
method: "POST",
body: JSON.stringify({ ids }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
function toggle(id: number) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
}
return (
<div>
<div className="toolbar">
<h2></h2>
<div className="toolbar-actions">
<button type="button" className="btn ghost" onClick={() => void refresh()}>
</button>
<button
type="button"
className="btn"
disabled={!selected.size}
onClick={() => void batchUpdate()}
>
({selected.size})
</button>
</div>
</div>
{err ? <div className="err">{err}</div> : null}
<div className="card-grid">
{nodes.map((n) => {
const s = pickStrategy(n);
const running = s.running === true || s.running === 1;
return (
<article key={n.id} className={`node-card ${n.online ? "online" : "offline"}`}>
<header className="node-card-head">
<label className="check">
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<strong>{n.name}</strong>
</label>
<span className={`pill ${n.online ? "ok" : "bad"}`}>
{n.online ? "在线" : "离线"}
</span>
</header>
<div className="node-meta mono">{n.base_url}</div>
<dl className="kv">
<div>
<dt></dt>
<dd>{s.mode}</dd>
</div>
<div>
<dt></dt>
<dd>{s.exchange}</dd>
</div>
<div>
<dt></dt>
<dd>
{running ? "运行中" : "已停"} · {s.phase}
</dd>
</div>
<div>
<dt></dt>
<dd>{s.rounds ?? "-"}</dd>
</div>
<div>
<dt></dt>
<dd>{s.market ? "已连接" : "断开"}</dd>
</div>
<div>
<dt>Token</dt>
<dd>{n.token_configured ? "已配对" : "未配对"}</dd>
</div>
</dl>
{n.error ? <div className="err soft">{n.error}</div> : null}
<div className="node-actions">
<button
type="button"
className="btn"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "start")}
>
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "pause")}
>
</button>
<button
type="button"
className="btn"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "login")}
>
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "update")}
>
</button>
</div>
</article>
);
})}
</div>
{!nodes.length ? (
<p className="meta"> Token</p>
) : null}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { FormEvent, useEffect, useState } from "react";
import { apiFetch, getUsername, setSession, type NodeCard } from "../api";
export default function SettingsPage() {
const [nodes, setNodes] = useState<NodeCard[]>([]);
const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("https://");
const [err, setErr] = useState("");
const [ok, setOk] = useState("");
const [lastToken, setLastToken] = useState<{ id: number; token: string } | null>(
null,
);
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [credBusy, setCredBusy] = useState(false);
async function load() {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
setNodes(r.nodes || []);
}
useEffect(() => {
load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex)));
}, []);
async function onAdd(e: FormEvent) {
e.preventDefault();
setErr("");
setOk("");
try {
await apiFetch("/api/nodes/", {
method: "POST",
body: JSON.stringify({ name: name.trim(), base_url: baseUrl.trim() }),
});
setName("");
setOk("已添加策略机");
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function onSaveCreds(e: FormEvent) {
e.preventDefault();
setErr("");
setOk("");
if (newPassword !== confirmPassword) {
setErr("两次新密码不一致");
return;
}
setCredBusy(true);
try {
const r = await apiFetch<{ token: string; username: string }>(
"/api/auth/change-credentials",
{
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_username: newUsername.trim(),
new_password: newPassword,
}),
},
);
setSession(r.token, r.username);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setOk("中控账号已更新");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setCredBusy(false);
}
}
async function genToken(id: number) {
setErr("");
setOk("");
try {
const r = await apiFetch<{ token: string; msg: string }>(
`/api/nodes/${id}/generate-token`,
{ method: "POST" },
);
setLastToken({ id, token: r.token });
setOk(r.msg || "已生成 Token");
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function remove(id: number) {
if (!window.confirm("确认删除该策略机?")) return;
setErr("");
try {
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
return (
<div>
<h2></h2>
<p className="meta">
访 Token Token
</p>
{err ? <div className="err">{err}</div> : null}
{ok ? <div className="ok">{ok}</div> : null}
<form className="add-form" onSubmit={onSaveCreds}>
<h3></h3>
<p className="meta"> admin / admin123</p>
<label>
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
required
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button>
</form>
{lastToken ? (
<div className="token-box">
<div>
#{lastToken.id} Token
</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
</button>
</div>
) : null}
<form className="add-form" onSubmit={onAdd}>
<h3></h3>
<label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
</button>
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th>URL</th>
<th>Token</th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<td className="mono">{n.base_url}</td>
<td>{n.token_configured ? "已生成" : "无"}</td>
<td className="row-actions">
<button type="button" className="btn" onClick={() => void genToken(n.id)}>
Token
</button>
<button
type="button"
className="btn ghost"
onClick={() => void remove(n.id)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
:root {
--bg: #0f1419;
--panel: #1a222c;
--line: #2a3542;
--text: #e8eef4;
--muted: #8b9aab;
--accent: #3d9cf0;
--ok: #3cb371;
--bad: #e35d5d;
--radius: 10px;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: radial-gradient(1200px 600px at 10% -10%, #1a3048, var(--bg));
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
.shell {
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 40px;
}
.header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
font-size: 1.15rem;
}
.nav {
display: flex;
gap: 8px;
}
.nav a {
color: var(--muted);
text-decoration: none;
padding: 8px 12px;
border-radius: 8px;
}
.nav a.active,
.nav a:hover {
color: var(--text);
background: var(--panel);
}
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.main h2 {
margin: 0 0 8px;
font-size: 1.25rem;
}
.meta {
color: var(--muted);
font-size: 0.9rem;
}
.mono {
font-family: ui-monospace, Consolas, monospace;
font-size: 0.85rem;
}
.btn {
border: 0;
background: var(--accent);
color: #fff;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.err {
background: rgba(227, 93, 93, 0.15);
color: #ffb4b4;
padding: 10px 12px;
border-radius: 8px;
margin: 10px 0;
}
.err.soft {
font-size: 0.85rem;
}
.ok {
background: rgba(60, 179, 113, 0.15);
color: #9df0c9;
padding: 10px 12px;
border-radius: 8px;
margin: 10px 0;
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px;
}
.login-box {
width: min(380px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-box h1 {
margin: 0;
font-size: 1.3rem;
}
.login-box label,
.add-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.9rem;
color: var(--muted);
}
input {
background: #10161d;
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
font: inherit;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.node-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.node-card.offline {
opacity: 0.85;
}
.node-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.check {
display: flex;
align-items: center;
gap: 8px;
}
.pill {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--line);
}
.pill.ok {
color: var(--ok);
border-color: rgba(60, 179, 113, 0.4);
}
.pill.bad {
color: var(--bad);
border-color: rgba(227, 93, 93, 0.4);
}
.kv {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
}
.kv dt {
color: var(--muted);
font-size: 0.75rem;
}
.kv dd {
margin: 2px 0 0;
font-size: 0.92rem;
}
.node-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.add-form {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
display: grid;
gap: 10px;
margin: 16px 0;
max-width: 520px;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
}
th,
td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.token-box {
background: #132033;
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px;
display: grid;
gap: 8px;
margin: 12px 0;
word-break: break-all;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
"/api": "http://127.0.0.1:5160",
"/health": "http://127.0.0.1:5160",
},
},
});
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy/lib/install.sh — 一键部署 # deploy/lib/install.sh — 一键部署策略机
set -e set -e
set -u set -u
if [ -n "${BASH_VERSION:-}" ]; then if [ -n "${BASH_VERSION:-}" ]; then
@@ -13,7 +13,7 @@ source "${LIB_DIR}/common.sh"
run_pipeline() { run_pipeline() {
local root="$1" local root="$1"
REPO_ROOT="${root}" REPO_ROOT="${root}"
step "构建并启动 (pull_and_restart.sh)" step "构建并启动策略机 (pull_and_restart.sh)"
bash "${REPO_ROOT}/deploy/pull_and_restart.sh" bash "${REPO_ROOT}/deploy/pull_and_restart.sh"
pm2_save_startup pm2_save_startup
verify_health || true verify_health || true
@@ -22,7 +22,7 @@ run_pipeline() {
install_fresh() { install_fresh() {
require_root require_root
step "一键部署 — 环境检测与依赖" step "一键部署策略机 — 环境检测与依赖"
ensure_system_deps ensure_system_deps
step "克隆仓库 → ${INSTALL_ROOT}" step "克隆仓库 → ${INSTALL_ROOT}"
if [[ -d "${INSTALL_ROOT}" ]]; then if [[ -d "${INSTALL_ROOT}" ]]; then
+2 -2
View File
@@ -13,10 +13,10 @@ source "${LIB_DIR}/common.sh"
main_update() { main_update() {
require_root require_root
if ! REPO_ROOT="$(resolve_repo_root)"; then if ! REPO_ROOT="$(resolve_repo_root)"; then
die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署」" die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署策略机"
fi fi
if ! repo_ready "${REPO_ROOT}"; then if ! repo_ready "${REPO_ROOT}"; then
die "安装不完整,请先执行「1) 一键部署」" die "安装不完整,请先执行「1) 一键部署策略机」"
fi fi
step "更新 — 环境检测 (缺则装,有则跳过)" step "更新 — 环境检测 (缺则装,有则跳过)"
+31 -6
View File
@@ -182,9 +182,11 @@ show_banner() {
} }
show_menu() { show_menu() {
echo " 1) 一键部署" echo " 1) 一键部署策略机"
echo " 2) 一键卸载" echo " 2) 一键部署中控机"
echo " 3) 更新" echo " 3) 一键卸载"
echo " 4) 更新策略机"
echo " 5) 更新中控机"
echo " 0) 退出" echo " 0) 退出"
echo "" echo ""
} }
@@ -205,6 +207,23 @@ cm_read() {
printf -v "${__var}" '%s' "${__line}" printf -v "${__var}" '%s' "${__line}"
} }
# 中控:依赖已齐 + 仓库在位后跑 control/deploy/update.sh
deploy_control_node() {
require_root
if ! REPO_ROOT="$(resolve_repo_root)"; then
die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署策略机」或完成仓库自举"
fi
if [[ ! -f "${REPO_ROOT}/control/deploy/update.sh" ]]; then
die "缺少 control/deploy/update.sh,请先 git pull 更新仓库"
fi
step "一键部署中控机 — 环境检测 (缺则装,有则跳过)"
ensure_system_deps
step "部署中控 (git pull + 构建 + pm2 eth-hedge-control)"
bash "${REPO_ROOT}/control/deploy/update.sh"
echo ""
log "中控部署完成 → http://<本机IP>:5160 默认账号 admin / admin123"
}
main_menu() { main_menu() {
bootstrap_repo bootstrap_repo
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
@@ -215,23 +234,29 @@ main_menu() {
show_banner show_banner
show_menu show_menu
local choice="" local choice=""
cm_read choice "请选择 [0-3]: " cm_read choice "请选择 [0-5]: "
case "${choice}" in case "${choice}" in
1) 1)
bash "${LIB_DIR}/install.sh" bash "${LIB_DIR}/install.sh"
;; ;;
2) 2)
bash "${LIB_DIR}/uninstall.sh" deploy_control_node
;; ;;
3) 3)
bash "${LIB_DIR}/uninstall.sh"
;;
4)
bash "${LIB_DIR}/update.sh" bash "${LIB_DIR}/update.sh"
;; ;;
5)
deploy_control_node
;;
0) 0)
echo "再见." echo "再见."
exit 0 exit 0
;; ;;
*) *)
echo "无效选项,请输入 0-3" echo "无效选项,请输入 0-5"
;; ;;
esac esac
echo "" echo ""
+64
View File
@@ -0,0 +1,64 @@
# 中控(Fleet Control)使用说明
中控与策略机在**同一仓库**,独立进程部署。中控跑在本地服务器,**不访问交易所**;只通过专用 API Token 读状态、启停、远程更新与免密登录策略机。
建议策略机与中控使用**同一 git 版本**(同 `main` / 同 tag),避免 `/api/fleet/*` 接口漂移。
## 架构一览
| 组件 | 端口 | PM2 名 | 目录 |
|------|------|--------|------|
| 策略机 | 5155 | `eth-hedge-api` | `backend/` + `frontend/` |
| 中控 | 5160 | `eth-hedge-control` | `control/backend` + `control/frontend` |
## 策略机:启用中控 Token
1. 部署含本功能的策略机代码(`deploy_remote.py` / 现有一键更新)。
2. 登录策略机 → **系统设置 → 登录账户 → 中控 API Token**
3. 粘贴中控生成的 Token 并保存(存哈希,不可回看明文)。
策略机 Fleet 接口(请求头 `X-Fleet-Token`):
- `GET /api/fleet/status`
- `POST /api/fleet/start` / `pause`
- `POST /api/fleet/update`(本机跑 `deploy/lib/update.sh`
- `POST /api/fleet/issue-login`(签发一次性免密登录票)
免密登录兑换:`POST /api/auth/fleet-exchange` `{ "ticket": "..." }`,或打开 `/fleet-login?ticket=...`
## 中控:本地安装与一键部署
在目标机执行(与策略机同一安装命令入口):
```bash
curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/manage.sh | bash
```
交互菜单:
1. **一键部署策略机** — 端口 5155 / `eth-hedge-api`
2. **一键部署中控机** — 端口 5160 / `eth-hedge-control`(自动补全 `.env.control`,已有值不覆盖)
3. 一键卸载
4. 更新策略机
5. 更新中控机
中控默认账号:`admin` / `admin123`;在中控 **系统设置** 可改用户名密码。
## 配对步骤
1. 中控 **系统设置** → 添加策略机(名称 + 公网 Base URL,如 `https://dc.hyf2.cc`)。
2. 点 **生成 Token** → 复制明文(只显示一次)。
3. 登录该策略机 → 保存同一 Token。
4. 回到中控 **监控区**:应显示在线与 Token 已配对。
## 监控区操作
- **启动 / 停止**:经 Token 调策略机 `/api/fleet/start|pause`LIVE 门禁仍在策略机侧)。
- **登录策略机**:中控代签一次性 ticket,新标签打开策略机并免密进入 `/plan`
- **更新代码 / 勾选更新**:中控调 `/api/fleet/update`,策略机本机 git pull + 构建 + reload**不会**自动 start 策略。
## 安全注意
- Fleet Token 可启停、更新、签发登录票,泄露后立即在两边轮换(中控重新生成 + 策略机覆盖保存)。
- 登录票约 60 秒、一次性;长期 Token 不会出现在浏览器地址栏。
- 中控建议仅内网访问;勿把 `.env.control` 与 Token 明文提交到 git。
+11
View File
@@ -5,6 +5,17 @@
--- ---
## 2026-07-30 — 中控 Fleet Control(同仓 / Token 运维)
### 变更
1. 策略机新增 `/api/fleet/*`status/start/pause/update/issue-login)与设置页 Token 保存;免密登录 `/fleet-login` + `/api/auth/fleet-exchange`
2. 同仓新增 `control/` 中控:监控卡片、系统设置、启停/更新/免密登录代理;Token 中控加密存储。
3. 策略机远程更新走 Fleet Token 调本机 `deploy/lib/update.sh`;中控本机一键部署 `scripts/deploy_control.py` + `control/deploy/update.sh`
4. 说明见 `docs/中控Fleet说明.md`
---
## 2026-07-30 — 策略 SoT 加固:资金门 fail-closed / 统一开仓管道 ## 2026-07-30 — 策略 SoT 加固:资金门 fail-closed / 统一开仓管道
### 变更 ### 变更
+1
View File
@@ -101,6 +101,7 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/fleet-login" element={<LoginPage />} />
<Route <Route
path="/plan" path="/plan"
element={ element={
+4 -1
View File
@@ -120,7 +120,10 @@ export async function apiFetch<T>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<T> { ): Promise<T> {
if (!path.startsWith("/api/auth/login")) { if (
!path.startsWith("/api/auth/login") &&
!path.startsWith("/api/auth/fleet-exchange")
) {
await ensureFreshToken(); await ensureFreshToken();
} }
const base = getApiBase(); const base = getApiBase();
+58 -2
View File
@@ -1,13 +1,59 @@
import { FormEvent, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { login, setSession } from "../api/client"; import { login, setSession } from "../api/client";
type ExchangeRes = {
token: string;
username: string;
expires_in: number;
};
export default function LoginPage() { export default function LoginPage() {
const nav = useNavigate(); const nav = useNavigate();
const [params] = useSearchParams();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ticketBusy, setTicketBusy] = useState(false);
useEffect(() => {
const ticket = (params.get("ticket") || "").trim();
if (!ticket) return;
let cancelled = false;
(async () => {
setTicketBusy(true);
setErr("");
try {
const res = await fetch(`${window.location.origin}/api/auth/fleet-exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket }),
});
const text = await res.text();
let data: ExchangeRes & { detail?: string } = { token: "", username: "", expires_in: 0 };
try {
data = text ? JSON.parse(text) : data;
} catch {
/* ignore */
}
if (!res.ok) {
throw new Error(data.detail || res.statusText || "兑换失败");
}
if (cancelled) return;
setSession(data.token, data.username, data.expires_in);
nav("/plan", { replace: true });
} catch (ex) {
if (!cancelled) {
setErr(ex instanceof Error ? ex.message : String(ex));
setTicketBusy(false);
}
}
})();
return () => {
cancelled = true;
};
}, [params, nav]);
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -24,6 +70,16 @@ export default function LoginPage() {
} }
} }
if (ticketBusy && !err) {
return (
<div className="login-wrap">
<div className="login-box">
<p className="meta"></p>
</div>
</div>
);
}
return ( return (
<div className="login-wrap"> <div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}> <form className="login-box" onSubmit={onSubmit}>
+89 -1
View File
@@ -57,6 +57,9 @@ export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [fleetConfigured, setFleetConfigured] = useState(false);
const [fleetTokenInput, setFleetTokenInput] = useState("");
const [fleetHint, setFleetHint] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [ok, setOk] = useState(""); const [ok, setOk] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -526,7 +529,15 @@ export default function SettingsPage() {
<button <button
type="button" type="button"
className={tab === "account" ? "tab active" : "tab"} className={tab === "account" ? "tab active" : "tab"}
onClick={() => setTab("account")} onClick={() => {
setTab("account");
apiFetch<{ configured: boolean; hint?: string }>("/api/fleet/meta")
.then((r) => {
setFleetConfigured(!!r.configured);
setFleetHint(r.hint || "");
})
.catch(() => undefined);
}}
> >
</button> </button>
@@ -1648,7 +1659,83 @@ export default function SettingsPage() {
<div className="card settings-card"> <div className="card settings-card">
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
{ok ? <div className="settings-ok">{ok}</div> : null} {ok ? <div className="settings-ok">{ok}</div> : null}
<section className="settings-section">
<h3> API Token</h3>
<p className="meta">
{fleetHint ||
"在中控生成 Token 后粘贴保存。用于远程启停、更新与免密登录。"}
</p>
<p className="meta">
{fleetConfigured ? "已配置" : "未配置"}
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="fleetTok">Token</label>
<input
id="fleetTok"
type="password"
value={fleetTokenInput}
onChange={(e) => setFleetTokenInput(e.target.value)}
placeholder="粘贴中控生成的 Token"
autoComplete="off"
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading || !fleetTokenInput.trim()}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
const r = await apiFetch<{ configured: boolean }>(
"/api/fleet/token",
{
method: "PUT",
body: JSON.stringify({ token: fleetTokenInput.trim() }),
},
);
setFleetConfigured(!!r.configured);
setFleetTokenInput("");
setOk("中控 Token 已保存");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
Token
</button>
<button
className="btn ghost"
type="button"
disabled={loading || !fleetConfigured}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
await apiFetch("/api/fleet/token", { method: "DELETE" });
setFleetConfigured(false);
setOk("已清除中控 Token");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<form onSubmit={onSaveCreds} className="settings-account-form"> <form onSubmit={onSaveCreds} className="settings-account-form">
<section className="settings-section">
<h3></h3>
<div className="settings-fields"> <div className="settings-fields">
<div className="field"> <div className="field">
<label htmlFor="user"></label> <label htmlFor="user"></label>
@@ -1695,6 +1782,7 @@ export default function SettingsPage() {
{loading ? "保存中…" : "保存账号"} {loading ? "保存中…" : "保存账号"}
</button> </button>
</div> </div>
</section>
</form> </form>
</div> </div>
) : null} ) : null}
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""本机/本地服务器一键部署中控(git pull + 构建 + pm2 reload)。
环境变量可选:
CONTROL_ROOT 默认 /opt/eth_hedge_sim与策略机同仓或本机仓库根
CONTROL_HOST 若设置则 SSH 远程执行否则在本机执行
CONTROL_USER 默认 root
CONTROL_PASS SSH 密码远程时必填
REPO_URL 默认 https://git.bz121.com/dekun/eth_hedge_sim.git
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
HOST = os.environ.get("CONTROL_HOST", "").strip()
USER = os.environ.get("CONTROL_USER", "root")
PASSWORD = os.environ.get("CONTROL_PASS", "")
ROOT = os.environ.get("CONTROL_ROOT", "").strip()
REPO = os.environ.get("REPO_URL", "https://git.bz121.com/dekun/eth_hedge_sim.git")
def _default_root() -> Path:
# scripts/deploy_control.py -> repo root
return Path(__file__).resolve().parents[1]
def local_update(root: Path) -> int:
script = root / "control" / "deploy" / "update.sh"
if not script.is_file():
print(f"missing {script}", file=sys.stderr)
return 2
if os.name == "nt":
print("Run on Linux local server, or set CONTROL_HOST for SSH.", file=sys.stderr)
return 2
env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
proc = subprocess.run(["bash", str(script)], cwd=str(root), env=env)
return int(proc.returncode)
def remote_update(root: str) -> int:
try:
import paramiko
except ImportError:
print("pip install paramiko", file=sys.stderr)
return 2
if not PASSWORD:
print("Set CONTROL_PASS for remote deploy.", file=sys.stderr)
return 2
remote = f"""
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if [[ ! -d '{root}/.git' ]]; then
mkdir -p "$(dirname '{root}')"
git clone '{REPO}' '{root}'
fi
bash '{root}/control/deploy/update.sh'
curl -fsS http://127.0.0.1:5160/health || true
"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print(f"connecting {USER}@{HOST} ...")
client.connect(HOST, username=USER, password=PASSWORD, timeout=30)
print("running control update ...")
_stdin, stdout, stderr = client.exec_command(remote, get_pty=True)
while True:
chunk = stdout.read(1024)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace") if isinstance(chunk, bytes) else chunk
print(text, end="", flush=True)
err_raw = stderr.read()
err = err_raw.decode("utf-8", errors="replace") if isinstance(err_raw, bytes) else err_raw
code = stdout.channel.recv_exit_status()
if err:
print(err, file=sys.stderr)
client.close()
print(f"exit={code}")
return int(code)
def main() -> int:
if HOST:
root = ROOT or "/opt/eth_hedge_sim"
return remote_update(root)
root = Path(ROOT) if ROOT else _default_root()
return local_update(root)
if __name__ == "__main__":
raise SystemExit(main())