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:
@@ -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
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""中控(Fleet)专用 API:X-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,请稍后探活",
|
||||||
|
}
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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}",
|
||||||
|
}
|
||||||
@@ -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(勿写进本文件)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
Generated
+1920
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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 / 统一开仓管道
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user