diff --git a/.env.control.example b/.env.control.example
new file mode 100644
index 0000000..54999ea
--- /dev/null
+++ b/.env.control.example
@@ -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
diff --git a/.gitignore b/.gitignore
index 4f755c9..a5d87a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# env / secrets
.env
.env.local
+.env.control
*.pem
# python
@@ -18,6 +19,10 @@ backend/data/*.db
backend/data/*.sqlite
backend/data/*.sqlite3
!backend/data/.gitkeep
+control/data/*
+!control/data/.gitkeep
+control/frontend/node_modules/
+control/frontend/dist/
# frontend
frontend/node_modules/
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
index 6e86542..608a40f 100644
--- a/backend/app/api/__init__.py
+++ b/backend/app/api/__init__.py
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from .auth_routes import router as auth_router
from .backup_routes import router as backup_router
+from .fleet import router as fleet_router
from .funds import router as funds_router
from .market import router as market_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(settings_router)
router.include_router(backup_router)
+router.include_router(fleet_router)
diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py
index 425a315..72d9c21 100644
--- a/backend/app/api/auth_routes.py
+++ b/backend/app/api/auth_routes.py
@@ -11,9 +11,14 @@ from pydantic import BaseModel, Field
from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials, upsert_env_file
from .auth import LoginRequest, LoginResponse, issue_token, require_user
+from .fleet import consume_login_ticket
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)
@@ -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)
async def login(
body: LoginRequest,
diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py
new file mode 100644
index 0000000..c22a025
--- /dev/null
+++ b/backend/app/api/fleet.py
@@ -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,请稍后探活",
+ }
diff --git a/backend/app/main.py b/backend/app/main.py
index 65111e0..4a0411f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -218,6 +218,7 @@ async def index_page():
@app.get("/stats")
@app.get("/settings")
@app.get("/login")
+@app.get("/fleet-login")
async def spa_pages(full_path: str = ""):
index = _DIST / "index.html"
if not index.exists():
diff --git a/backend/tests/test_fleet.py b/backend/tests/test_fleet.py
new file mode 100644
index 0000000..e42cbbd
--- /dev/null
+++ b/backend/tests/test_fleet.py
@@ -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)
diff --git a/control/backend/app/__init__.py b/control/backend/app/__init__.py
new file mode 100644
index 0000000..b9972be
--- /dev/null
+++ b/control/backend/app/__init__.py
@@ -0,0 +1 @@
+# 使 `uvicorn app.main:app` 在 control/backend 下可运行
diff --git a/control/backend/app/api/__init__.py b/control/backend/app/api/__init__.py
new file mode 100644
index 0000000..a0336b4
--- /dev/null
+++ b/control/backend/app/api/__init__.py
@@ -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)
diff --git a/control/backend/app/api/auth_routes.py b/control/backend/app/api/auth_routes.py
new file mode 100644
index 0000000..b361bd6
--- /dev/null
+++ b/control/backend/app/api/auth_routes.py
@@ -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,
+ }
diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py
new file mode 100644
index 0000000..50b6401
--- /dev/null
+++ b/control/backend/app/api/nodes.py
@@ -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,
+ }
diff --git a/control/backend/app/auth.py b/control/backend/app/auth.py
new file mode 100644
index 0000000..89ae2db
--- /dev/null
+++ b/control/backend/app/auth.py
@@ -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)
diff --git a/control/backend/app/config.py b/control/backend/app/config.py
new file mode 100644
index 0000000..c789008
--- /dev/null
+++ b/control/backend/app/config.py
@@ -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()
diff --git a/control/backend/app/crypto.py b/control/backend/app/crypto.py
new file mode 100644
index 0000000..1dce6c7
--- /dev/null
+++ b/control/backend/app/crypto.py
@@ -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")
diff --git a/control/backend/app/db.py b/control/backend/app/db.py
new file mode 100644
index 0000000..0ef881d
--- /dev/null
+++ b/control/backend/app/db.py
@@ -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
diff --git a/control/backend/app/envfile.py b/control/backend/app/envfile.py
new file mode 100644
index 0000000..690840c
--- /dev/null
+++ b/control/backend/app/envfile.py
@@ -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()
diff --git a/control/backend/app/main.py b/control/backend/app/main.py
new file mode 100644
index 0000000..f802793
--- /dev/null
+++ b/control/backend/app/main.py
@@ -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")
diff --git a/control/backend/app/proxy.py b/control/backend/app/proxy.py
new file mode 100644
index 0000000..b0eeec1
--- /dev/null
+++ b/control/backend/app/proxy.py
@@ -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}",
+ }
diff --git a/control/data/.gitkeep b/control/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/control/deploy/ecosystem.config.cjs b/control/deploy/ecosystem.config.cjs
new file mode 100644
index 0000000..f3ee623
--- /dev/null
+++ b/control/deploy/ecosystem.config.cjs
@@ -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(勿写进本文件)
+ },
+ ],
+};
diff --git a/control/deploy/update.sh b/control/deploy/update.sh
new file mode 100644
index 0000000..d6521ba
--- /dev/null
+++ b/control/deploy/update.sh
@@ -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"
diff --git a/control/frontend/index.html b/control/frontend/index.html
new file mode 100644
index 0000000..dc137ea
--- /dev/null
+++ b/control/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 比特骆驼中控
+
+
+
+
+
+
diff --git a/control/frontend/package-lock.json b/control/frontend/package-lock.json
new file mode 100644
index 0000000..4cbdbf6
--- /dev/null
+++ b/control/frontend/package-lock.json
@@ -0,0 +1,1920 @@
+{
+ "name": "bitcamel-control-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bitcamel-control-web",
+ "version": "0.1.0",
+ "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"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+ "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+ "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+ "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+ "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+ "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+ "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+ "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+ "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+ "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+ "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+ "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+ "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+ "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+ "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+ "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+ "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+ "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+ "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+ "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+ "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+ "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+ "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+ "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.7",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
+ "integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.398",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
+ "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+ "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.3",
+ "@rollup/rollup-android-arm64": "4.62.3",
+ "@rollup/rollup-darwin-arm64": "4.62.3",
+ "@rollup/rollup-darwin-x64": "4.62.3",
+ "@rollup/rollup-freebsd-arm64": "4.62.3",
+ "@rollup/rollup-freebsd-x64": "4.62.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+ "@rollup/rollup-linux-arm64-musl": "4.62.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+ "@rollup/rollup-linux-loong64-musl": "4.62.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-musl": "4.62.3",
+ "@rollup/rollup-openbsd-x64": "4.62.3",
+ "@rollup/rollup-openharmony-arm64": "4.62.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+ "@rollup/rollup-win32-x64-gnu": "4.62.3",
+ "@rollup/rollup-win32-x64-msvc": "4.62.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/control/frontend/package.json b/control/frontend/package.json
new file mode 100644
index 0000000..afc442b
--- /dev/null
+++ b/control/frontend/package.json
@@ -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"
+ }
+}
diff --git a/control/frontend/src/App.tsx b/control/frontend/src/App.tsx
new file mode 100644
index 0000000..4d98c5b
--- /dev/null
+++ b/control/frontend/src/App.tsx
@@ -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 (
+
+ );
+}
+
+function RequireAuth({ children }: { children: ReactNode }) {
+ if (!getToken()) return ;
+ return {children};
+}
+
+export default function App() {
+ return (
+
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+ } />
+
+ );
+}
diff --git a/control/frontend/src/api.ts b/control/frontend/src/api.ts
new file mode 100644
index 0000000..dd84012
--- /dev/null
+++ b/control/frontend/src/api.ts
@@ -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(
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ 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 | null;
+ fleet?: Record | null;
+ error?: string | null;
+};
diff --git a/control/frontend/src/main.tsx b/control/frontend/src/main.tsx
new file mode 100644
index 0000000..efbf46d
--- /dev/null
+++ b/control/frontend/src/main.tsx
@@ -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(
+
+
+
+
+ ,
+);
diff --git a/control/frontend/src/pages/Login.tsx b/control/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..958f783
--- /dev/null
+++ b/control/frontend/src/pages/Login.tsx
@@ -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 (
+
+ );
+}
diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx
new file mode 100644
index 0000000..fa9e9f2
--- /dev/null
+++ b/control/frontend/src/pages/Monitor.tsx
@@ -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;
+ const health = (n.health || {}) as Record;
+ const strat =
+ (fleet.strategy as Record | undefined) ||
+ (health.strategy as Record | 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([]);
+ const [err, setErr] = useState("");
+ const [busy, setBusy] = useState>({});
+ const [selected, setSelected] = useState>(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 (
+
+
+
监控区
+
+
+
+
+
+ {err ?
{err}
: null}
+
+ {nodes.map((n) => {
+ const s = pickStrategy(n);
+ const running = s.running === true || s.running === 1;
+ return (
+
+
+ {n.base_url}
+
+
+
- 模式
+ - {s.mode}
+
+
+
- 交易所
+ - {s.exchange}
+
+
+
- 策略
+ -
+ {running ? "运行中" : "已停"} · {s.phase}
+
+
+
+
- 轮次
+ - {s.rounds ?? "-"}
+
+
+
- 行情
+ - {s.market ? "已连接" : "断开"}
+
+
+
- Token
+ - {n.token_configured ? "已配对" : "未配对"}
+
+
+ {n.error ? {n.error}
: null}
+
+
+
+
+
+
+
+ );
+ })}
+
+ {!nodes.length ? (
+
暂无策略机。请到「系统设置」添加并生成 Token。
+ ) : null}
+
+ );
+}
diff --git a/control/frontend/src/pages/Settings.tsx b/control/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..ba85aa2
--- /dev/null
+++ b/control/frontend/src/pages/Settings.tsx
@@ -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([]);
+ 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 (
+
+
系统设置
+
+ 中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
+
+ {err ?
{err}
: null}
+ {ok ?
{ok}
: null}
+
+
+
+ {lastToken ? (
+
+
+ 节点 #{lastToken.id} 新 Token(只显示一次,请复制):
+
+
{lastToken.token}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ | ID |
+ 名称 |
+ URL |
+ Token |
+ 操作 |
+
+
+
+ {nodes.map((n) => (
+
+ | {n.id} |
+ {n.name} |
+ {n.base_url} |
+ {n.token_configured ? "已生成" : "无"} |
+
+
+
+ |
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/control/frontend/src/styles.css b/control/frontend/src/styles.css
new file mode 100644
index 0000000..5a7250d
--- /dev/null
+++ b/control/frontend/src/styles.css
@@ -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;
+}
diff --git a/control/frontend/src/vite-env.d.ts b/control/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/control/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/control/frontend/tsconfig.json b/control/frontend/tsconfig.json
new file mode 100644
index 0000000..7350c19
--- /dev/null
+++ b/control/frontend/tsconfig.json
@@ -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"]
+}
diff --git a/control/frontend/vite.config.ts b/control/frontend/vite.config.ts
new file mode 100644
index 0000000..7b1ca7e
--- /dev/null
+++ b/control/frontend/vite.config.ts
@@ -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",
+ },
+ },
+});
diff --git a/deploy/lib/install.sh b/deploy/lib/install.sh
index c420a18..9b088c3 100755
--- a/deploy/lib/install.sh
+++ b/deploy/lib/install.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# deploy/lib/install.sh — 一键部署
+# deploy/lib/install.sh — 一键部署策略机
set -e
set -u
if [ -n "${BASH_VERSION:-}" ]; then
@@ -13,7 +13,7 @@ source "${LIB_DIR}/common.sh"
run_pipeline() {
local root="$1"
REPO_ROOT="${root}"
- step "构建并启动 (pull_and_restart.sh)"
+ step "构建并启动策略机 (pull_and_restart.sh)"
bash "${REPO_ROOT}/deploy/pull_and_restart.sh"
pm2_save_startup
verify_health || true
@@ -22,7 +22,7 @@ run_pipeline() {
install_fresh() {
require_root
- step "一键部署 — 环境检测与依赖"
+ step "一键部署策略机 — 环境检测与依赖"
ensure_system_deps
step "克隆仓库 → ${INSTALL_ROOT}"
if [[ -d "${INSTALL_ROOT}" ]]; then
diff --git a/deploy/lib/update.sh b/deploy/lib/update.sh
index 76b0dc8..5efa13c 100755
--- a/deploy/lib/update.sh
+++ b/deploy/lib/update.sh
@@ -13,10 +13,10 @@ source "${LIB_DIR}/common.sh"
main_update() {
require_root
if ! REPO_ROOT="$(resolve_repo_root)"; then
- die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署」"
+ die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署策略机」"
fi
if ! repo_ready "${REPO_ROOT}"; then
- die "安装不完整,请先执行「1) 一键部署」"
+ die "安装不完整,请先执行「1) 一键部署策略机」"
fi
step "更新 — 环境检测 (缺则装,有则跳过)"
diff --git a/deploy/manage.sh b/deploy/manage.sh
index dfff46a..df15cad 100755
--- a/deploy/manage.sh
+++ b/deploy/manage.sh
@@ -182,9 +182,11 @@ show_banner() {
}
show_menu() {
- echo " 1) 一键部署"
- echo " 2) 一键卸载"
- echo " 3) 更新"
+ echo " 1) 一键部署策略机"
+ echo " 2) 一键部署中控机"
+ echo " 3) 一键卸载"
+ echo " 4) 更新策略机"
+ echo " 5) 更新中控机"
echo " 0) 退出"
echo ""
}
@@ -205,6 +207,23 @@ cm_read() {
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() {
bootstrap_repo
# shellcheck source=lib/common.sh
@@ -215,23 +234,29 @@ main_menu() {
show_banner
show_menu
local choice=""
- cm_read choice "请选择 [0-3]: "
+ cm_read choice "请选择 [0-5]: "
case "${choice}" in
1)
bash "${LIB_DIR}/install.sh"
;;
2)
- bash "${LIB_DIR}/uninstall.sh"
+ deploy_control_node
;;
3)
+ bash "${LIB_DIR}/uninstall.sh"
+ ;;
+ 4)
bash "${LIB_DIR}/update.sh"
;;
+ 5)
+ deploy_control_node
+ ;;
0)
echo "再见."
exit 0
;;
*)
- echo "无效选项,请输入 0-3"
+ echo "无效选项,请输入 0-5"
;;
esac
echo ""
diff --git a/docs/中控Fleet说明.md b/docs/中控Fleet说明.md
new file mode 100644
index 0000000..b017f40
--- /dev/null
+++ b/docs/中控Fleet说明.md
@@ -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。
diff --git a/docs/更新说明.md b/docs/更新说明.md
index 9f30e3e..6f84a57 100644
--- a/docs/更新说明.md
+++ b/docs/更新说明.md
@@ -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 / 统一开仓管道
### 变更
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7785d05..b25b6af 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -101,6 +101,7 @@ export default function App() {
return (
} />
+ } />
(
path: string,
options: RequestInit = {},
): Promise {
- if (!path.startsWith("/api/auth/login")) {
+ if (
+ !path.startsWith("/api/auth/login") &&
+ !path.startsWith("/api/auth/fleet-exchange")
+ ) {
await ensureFreshToken();
}
const base = getApiBase();
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 3f028cd..f157127 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -1,13 +1,59 @@
-import { FormEvent, useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { FormEvent, useEffect, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
import { login, setSession } from "../api/client";
+type ExchangeRes = {
+ token: string;
+ username: string;
+ expires_in: number;
+};
+
export default function LoginPage() {
const nav = useNavigate();
+ const [params] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
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) {
e.preventDefault();
@@ -24,6 +70,16 @@ export default function LoginPage() {
}
}
+ if (ticketBusy && !err) {
+ return (
+
+ );
+ }
+
return (