From da5eb4c18cbb19a25b4e7b8c459ada5e2b341df0 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 10:46:40 +0800 Subject: [PATCH] Add Fleet control plane and split manage.sh deploy menu. Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy. Co-authored-by: Cursor --- .env.control.example | 10 + .gitignore | 5 + backend/app/api/__init__.py | 2 + backend/app/api/auth_routes.py | 22 + backend/app/api/fleet.py | 273 ++++ backend/app/main.py | 1 + backend/tests/test_fleet.py | 48 + control/backend/app/__init__.py | 1 + control/backend/app/api/__init__.py | 8 + control/backend/app/api/auth_routes.py | 81 + control/backend/app/api/nodes.py | 250 +++ control/backend/app/auth.py | 77 + control/backend/app/config.py | 46 + control/backend/app/crypto.py | 42 + control/backend/app/db.py | 119 ++ control/backend/app/envfile.py | 121 ++ control/backend/app/main.py | 74 + control/backend/app/proxy.py | 73 + control/data/.gitkeep | 0 control/deploy/ecosystem.config.cjs | 15 + control/deploy/update.sh | 49 + control/frontend/index.html | 12 + control/frontend/package-lock.json | 1920 +++++++++++++++++++++++ control/frontend/package.json | 23 + control/frontend/src/App.tsx | 70 + control/frontend/src/api.ts | 75 + control/frontend/src/main.tsx | 13 + control/frontend/src/pages/Login.tsx | 51 + control/frontend/src/pages/Monitor.tsx | 210 +++ control/frontend/src/pages/Settings.tsx | 241 +++ control/frontend/src/styles.css | 299 ++++ control/frontend/src/vite-env.d.ts | 1 + control/frontend/tsconfig.json | 21 + control/frontend/vite.config.ts | 13 + deploy/lib/install.sh | 6 +- deploy/lib/update.sh | 4 +- deploy/manage.sh | 37 +- docs/中控Fleet说明.md | 64 + docs/更新说明.md | 11 + frontend/src/App.tsx | 1 + frontend/src/api/client.ts | 5 +- frontend/src/pages/Login.tsx | 60 +- frontend/src/pages/Settings.tsx | 90 +- scripts/deploy_control.py | 94 ++ 44 files changed, 4623 insertions(+), 15 deletions(-) create mode 100644 .env.control.example create mode 100644 backend/app/api/fleet.py create mode 100644 backend/tests/test_fleet.py create mode 100644 control/backend/app/__init__.py create mode 100644 control/backend/app/api/__init__.py create mode 100644 control/backend/app/api/auth_routes.py create mode 100644 control/backend/app/api/nodes.py create mode 100644 control/backend/app/auth.py create mode 100644 control/backend/app/config.py create mode 100644 control/backend/app/crypto.py create mode 100644 control/backend/app/db.py create mode 100644 control/backend/app/envfile.py create mode 100644 control/backend/app/main.py create mode 100644 control/backend/app/proxy.py create mode 100644 control/data/.gitkeep create mode 100644 control/deploy/ecosystem.config.cjs create mode 100644 control/deploy/update.sh create mode 100644 control/frontend/index.html create mode 100644 control/frontend/package-lock.json create mode 100644 control/frontend/package.json create mode 100644 control/frontend/src/App.tsx create mode 100644 control/frontend/src/api.ts create mode 100644 control/frontend/src/main.tsx create mode 100644 control/frontend/src/pages/Login.tsx create mode 100644 control/frontend/src/pages/Monitor.tsx create mode 100644 control/frontend/src/pages/Settings.tsx create mode 100644 control/frontend/src/styles.css create mode 100644 control/frontend/src/vite-env.d.ts create mode 100644 control/frontend/tsconfig.json create mode 100644 control/frontend/vite.config.ts create mode 100644 docs/中控Fleet说明.md create mode 100644 scripts/deploy_control.py 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 ( +
+
+
比特骆驼中控
+ +
+ {user} + +
+
+
{children}
+
+ ); +} + +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 ( +
+
+

比特骆驼中控

+

本地运维面板 · 默认 admin / admin123

+ {err ?
{err}
: null} + + + +
+
+ ); +} 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.online ? "在线" : "离线"} + +
+
{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} + +
+

中控登录账号

+

默认 admin / admin123,建议首次登录后修改。

+ + + + + +
+ + {lastToken ? ( +
+
+ 节点 #{lastToken.id} 新 Token(只显示一次,请复制): +
+ {lastToken.token} + +
+ ) : null} + +
+

添加策略机

+ + + +
+ +
+ + + + + + + + + + + + {nodes.map((n) => ( + + + + + + + + ))} + +
ID名称URLToken操作
{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 (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6efaa5c..cde2def 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -57,6 +57,9 @@ export default function SettingsPage() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [fleetConfigured, setFleetConfigured] = useState(false); + const [fleetTokenInput, setFleetTokenInput] = useState(""); + const [fleetHint, setFleetHint] = useState(""); const [err, setErr] = useState(""); const [ok, setOk] = useState(""); const [loading, setLoading] = useState(false); @@ -526,7 +529,15 @@ export default function SettingsPage() { @@ -1648,7 +1659,83 @@ export default function SettingsPage() {
{err ?
{err}
: null} {ok ?
{ok}
: null} +
+

中控 API Token

+

+ {fleetHint || + "在中控生成 Token 后粘贴保存。用于远程启停、更新与免密登录。"} +

+

+ 状态:{fleetConfigured ? "已配置" : "未配置"} +

+
+
+ + setFleetTokenInput(e.target.value)} + placeholder="粘贴中控生成的 Token" + autoComplete="off" + /> +
+
+
+ + +
+
+
+

登录账号

@@ -1695,6 +1782,7 @@ export default function SettingsPage() { {loading ? "保存中…" : "保存账号"}
+
) : null} diff --git a/scripts/deploy_control.py b/scripts/deploy_control.py new file mode 100644 index 0000000..0cb5932 --- /dev/null +++ b/scripts/deploy_control.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""本机/本地服务器一键部署中控(git pull + 构建 + pm2 reload)。 + +环境变量(可选): + CONTROL_ROOT 默认 /opt/eth_hedge_sim(与策略机同仓)或本机仓库根 + CONTROL_HOST 若设置则 SSH 远程执行;否则在本机执行 + CONTROL_USER 默认 root + CONTROL_PASS SSH 密码(远程时必填) + REPO_URL 默认 https://git.bz121.com/dekun/eth_hedge_sim.git +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +HOST = os.environ.get("CONTROL_HOST", "").strip() +USER = os.environ.get("CONTROL_USER", "root") +PASSWORD = os.environ.get("CONTROL_PASS", "") +ROOT = os.environ.get("CONTROL_ROOT", "").strip() +REPO = os.environ.get("REPO_URL", "https://git.bz121.com/dekun/eth_hedge_sim.git") + + +def _default_root() -> Path: + # scripts/deploy_control.py -> repo root + return Path(__file__).resolve().parents[1] + + +def local_update(root: Path) -> int: + script = root / "control" / "deploy" / "update.sh" + if not script.is_file(): + print(f"missing {script}", file=sys.stderr) + return 2 + if os.name == "nt": + print("Run on Linux local server, or set CONTROL_HOST for SSH.", file=sys.stderr) + return 2 + env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"} + proc = subprocess.run(["bash", str(script)], cwd=str(root), env=env) + return int(proc.returncode) + + +def remote_update(root: str) -> int: + try: + import paramiko + except ImportError: + print("pip install paramiko", file=sys.stderr) + return 2 + if not PASSWORD: + print("Set CONTROL_PASS for remote deploy.", file=sys.stderr) + return 2 + remote = f""" +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +if [[ ! -d '{root}/.git' ]]; then + mkdir -p "$(dirname '{root}')" + git clone '{REPO}' '{root}' +fi +bash '{root}/control/deploy/update.sh' +curl -fsS http://127.0.0.1:5160/health || true +""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + print(f"connecting {USER}@{HOST} ...") + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + print("running control update ...") + _stdin, stdout, stderr = client.exec_command(remote, get_pty=True) + while True: + chunk = stdout.read(1024) + if not chunk: + break + text = chunk.decode("utf-8", errors="replace") if isinstance(chunk, bytes) else chunk + print(text, end="", flush=True) + err_raw = stderr.read() + err = err_raw.decode("utf-8", errors="replace") if isinstance(err_raw, bytes) else err_raw + code = stdout.channel.recv_exit_status() + if err: + print(err, file=sys.stderr) + client.close() + print(f"exit={code}") + return int(code) + + +def main() -> int: + if HOST: + root = ROOT or "/opt/eth_hedge_sim" + return remote_update(root) + root = Path(ROOT) if ROOT else _default_root() + return local_update(root) + + +if __name__ == "__main__": + raise SystemExit(main())