diff --git a/app/api.py b/app/api.py index 2b4cf01..d168298 100644 --- a/app/api.py +++ b/app/api.py @@ -72,6 +72,7 @@ from app.services.common import get_node_by_sn, get_user_by_wallet from app.services.datacenter import approve_application, list_applications, reject_application, submit_application from app.services.hardware import HardwareReport from app.services.hardware import display_node_id, displayed_population +from app.services.alpha_queue import assign_queued_alpha_task, maybe_generate_alpha_batch from app.services.heartbeat import assert_node_token, record_heartbeat, set_node_banned from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client @@ -327,6 +328,8 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut: client_ip=client_ip(request), hardware_fingerprint=body.hardware_fingerprint, ) + maybe_generate_alpha_batch(db) + assigned = assign_queued_alpha_task(db, node) days = node.recovery_days or 540 return HeartbeatOut( sn=node.sn, @@ -340,6 +343,7 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut: recoverable_stake_amount=node.recoverable_stake_amount, purchase_amount=node.purchase_amount, estimated_daily_points=money(node.purchase_amount / Decimal(days)), + assigned_task=_task_out(assigned) if assigned is not None else None, ) @@ -514,8 +518,8 @@ def _admin_task_out(task: ComputeTask) -> AdminTaskOut: return AdminTaskOut( task_id=task.id, task_type=task.task_type, - target_node_sn=node.sn, - display_node_id=node.display_node_id, + target_node_sn=node.sn if node is not None else "", + display_node_id=node.display_node_id if node is not None else 0, status=task.status, payload=task.payload or {}, result=task.result, diff --git a/app/models.py b/app/models.py index dede817..79e4be1 100644 --- a/app/models.py +++ b/app/models.py @@ -249,7 +249,7 @@ class ComputeTask(Base): ) id: Mapped[str] = mapped_column(String(36), primary_key=True, comment="task_id") - node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True) + node_id: Mapped[int | None] = mapped_column(ForeignKey("compute_nodes.id"), nullable=True, index=True) task_type: Mapped[str] = mapped_column(String(32), default="simulated_compute") payload: Mapped[dict] = mapped_column(JSON) status: Mapped[TaskStatus] = mapped_column(_enum(TaskStatus), default=TaskStatus.PENDING, index=True) @@ -261,7 +261,26 @@ class ComputeTask(Base): success: Mapped[bool | None] = mapped_column() result: Mapped[dict | None] = mapped_column(JSON, comment="节点回传的任务结果") - node: Mapped[ComputeNode] = relationship(back_populates="tasks") + node: Mapped[ComputeNode | None] = relationship(back_populates="tasks") + + +class AlphaFactor(Base): + """节点回传且达到 IC 门槛的因子。""" + + __tablename__ = "alpha_factors" + __table_args__ = ( + Index("ix_alpha_factor_task", "task_id"), + Index("ix_alpha_factor_ic", "ic"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(ForeignKey("compute_tasks.id"), index=True) + node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True) + formula: Mapped[str] = mapped_column(Text) + ic: Mapped[float] = mapped_column(Float) + symbol: Mapped[str | None] = mapped_column(String(32)) + seed: Mapped[str | None] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow) class NodeYield(Base): diff --git a/app/scheduler.py b/app/scheduler.py index 75d204b..1aad168 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -13,6 +13,7 @@ from zoneinfo import ZoneInfo from app.config import settings from app.database import session_scope from app.jobs.daily_yield import run_daily_yield_cron +from app.services.alpha_queue import maybe_generate_alpha_batch from app.services.heartbeat import mark_stale_nodes from app.services.locks import acquire_lock from app.services.tasks import dispatch_random_tasks @@ -45,6 +46,13 @@ def start_scheduler() -> None: replace_existing=True, misfire_grace_time=120, ) + scheduler.add_job( + _guard(_alpha_job), + IntervalTrigger(hours=2, timezone=UTC), + id="alpha_batch", + replace_existing=True, + misfire_grace_time=600, + ) yield_zone = ZoneInfo(settings.yield_timezone) scheduler.add_job( _guard(_yield_job), @@ -98,6 +106,20 @@ def _dispatch_job() -> None: tasks = dispatch_random_tasks(session) if tasks: logger.info("dispatched %s tasks", len(tasks)) + _refill_alpha_queue(force=False) + + +def _alpha_job() -> None: + _refill_alpha_queue(force=True) + + +def _refill_alpha_queue(force: bool) -> None: + if not acquire_lock("alpha_batch", timedelta(minutes=5)): + return + with session_scope() as session: + created = maybe_generate_alpha_batch(session, force=force) + if created: + logger.info("alpha batch slices=%s force=%s", len(created), force) def _yield_job() -> None: diff --git a/app/schema_upgrade.py b/app/schema_upgrade.py index 048f371..ebc00a6 100644 --- a/app/schema_upgrade.py +++ b/app/schema_upgrade.py @@ -73,15 +73,15 @@ def upgrade_schema() -> None: "ON compute_nodes (hardware_fingerprint)" ) index_ready = "compute_nodes" in tables and ("node_index" in node_columns or any("node_index" in item for item in statements)) - if not statements and not need_username_index and not index_ready: - return - with engine.begin() as conn: - for statement in statements: - conn.execute(text(statement)) - if need_username_index: - conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)")) - if index_ready: - _backfill_node_index(conn, "recovery_days" in node_columns or any("recovery_days" in item for item in statements)) + if statements or need_username_index or index_ready: + with engine.begin() as conn: + for statement in statements: + conn.execute(text(statement)) + if need_username_index: + conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)")) + if index_ready: + _backfill_node_index(conn, "recovery_days" in node_columns or any("recovery_days" in item for item in statements)) + _relax_task_node_id() def _backfill_node_index(conn, has_recovery: bool) -> None: @@ -97,6 +97,69 @@ def _backfill_node_index(conn, has_recovery: bool) -> None: conn.execute(text("UPDATE compute_nodes SET recovery_days = 30 WHERE node_index BETWEEN 1 AND 20")) +def _relax_task_node_id(bind=None) -> None: + """旧库 compute_tasks.node_id 为 NOT NULL,排队任务需要改成可空。SQLite 不能直接改空约束,整表重建。""" + target = bind or engine + inspector = inspect(target) + if "compute_tasks" not in inspector.get_table_names(): + return + columns = {column["name"]: column for column in inspector.get_columns("compute_tasks")} + node_column = columns.get("node_id") + if node_column is None or node_column.get("nullable", True): + return + names = [ + name + for name in ( + "id", + "node_id", + "task_type", + "payload", + "status", + "push_status", + "push_error", + "dispatched_at", + "deadline_at", + "responded_at", + "success", + "result", + ) + if name in columns + ] + copied = ", ".join(names) + with target.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + conn.execute(text("PRAGMA foreign_keys=OFF")) + conn.execute(text("DROP TABLE IF EXISTS compute_tasks_queue")) + conn.execute( + text( + """ + CREATE TABLE compute_tasks_queue ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + node_id INTEGER, + task_type VARCHAR(32) NOT NULL, + payload JSON NOT NULL, + status VARCHAR(16) NOT NULL, + push_status VARCHAR(16) NOT NULL, + push_error VARCHAR(255), + dispatched_at DATETIME NOT NULL, + deadline_at DATETIME NOT NULL, + responded_at DATETIME, + success BOOLEAN, + result JSON, + FOREIGN KEY(node_id) REFERENCES compute_nodes (id) + ) + """ + ) + ) + conn.execute(text(f"INSERT INTO compute_tasks_queue ({copied}) SELECT {copied} FROM compute_tasks")) + conn.execute(text("DROP TABLE compute_tasks")) + conn.execute(text("ALTER TABLE compute_tasks_queue RENAME TO compute_tasks")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_compute_tasks_node_id ON compute_tasks (node_id)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_compute_tasks_status ON compute_tasks (status)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_node_status ON compute_tasks (node_id, status)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_dispatched_at ON compute_tasks (dispatched_at)")) + conn.execute(text("PRAGMA foreign_keys=ON")) + + def _has_username_index(inspector) -> bool: if "users" not in inspector.get_table_names(): return False diff --git a/app/schemas.py b/app/schemas.py index 6b151b2..1b82c61 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -128,6 +128,7 @@ class HeartbeatOut(APIModel): recoverable_stake_amount: MoneyText | None = None purchase_amount: MoneyText | None = None estimated_daily_points: MoneyText | None = None + assigned_task: TaskOut | None = None class TaskOut(APIModel): @@ -358,3 +359,4 @@ class DatacenterView(DatacenterOut): DashboardOut.model_rebuild() +HeartbeatOut.model_rebuild() diff --git a/app/services/alpha_queue.py b/app/services/alpha_queue.py new file mode 100644 index 0000000..ad4d6e5 --- /dev/null +++ b/app/services/alpha_queue.py @@ -0,0 +1,200 @@ +"""量化因子挖掘的排队与自动调度。 + +没有待领取的 alpha_mining 任务时补一批;距上一批已满 2 小时时再补一批。 +切片先不绑定节点,心跳到来时再改成 processing。高 IC 因子写入 alpha_factors。 +""" + +from __future__ import annotations + +import random +import secrets +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.clock import utcnow +from app.config import settings +from app.models import AlphaFactor, ComputeNode, ComputeTask, PushStatus, TaskStatus + +ALPHA_BATCH_HOURS = 2 +MIN_ABS_IC = 0.03 +SLICE_COUNT = 3 +QUEUE_HOLD_DAYS = 7 +OPERATORS = ( + "ts_mean", + "ts_std", + "rank", + "delta", + "decay_linear", + "correlation", + "ts_rank", + "scale", +) +SYMBOLS = ("BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT") + + +def maybe_generate_alpha_batch( + session: Session, + now: datetime | None = None, + force: bool = False, +) -> list[ComputeTask]: + """队列里没有 pending 的挖掘任务,或距上一批已满 2 小时,就生成新的一批切片。""" + moment = now or utcnow() + pending = session.scalar( + select(func.count()) + .select_from(ComputeTask) + .where( + ComputeTask.task_type == "alpha_mining", + ComputeTask.status == TaskStatus.PENDING, + ) + ) or 0 + latest = _as_utc( + session.scalar(select(func.max(ComputeTask.dispatched_at)).where(ComputeTask.task_type == "alpha_mining")) + ) + due = latest is None or moment - latest >= timedelta(hours=ALPHA_BATCH_HOURS) + if pending and not due and not force: + return [] + return _create_alpha_batch(session, moment) + + +def assign_queued_alpha_task( + session: Session, + node: ComputeNode, + now: datetime | None = None, +) -> ComputeTask | None: + """把最早的一条未绑定切片派给当前节点。节点已有执行中的挖掘任务时不再加派。""" + moment = now or utcnow() + holding = session.scalar( + select(ComputeTask.id).where( + ComputeTask.node_id == node.id, + ComputeTask.task_type == "alpha_mining", + ComputeTask.status == TaskStatus.PROCESSING, + ) + ) + if holding is not None: + return None + task = session.scalar( + select(ComputeTask) + .where( + ComputeTask.task_type == "alpha_mining", + ComputeTask.status == TaskStatus.PENDING, + ComputeTask.node_id.is_(None), + ) + .order_by(ComputeTask.dispatched_at, ComputeTask.id) + .limit(1) + ) + if task is None: + return None + task.node_id = node.id + task.status = TaskStatus.PROCESSING + task.deadline_at = moment + timedelta(minutes=settings.task_deadline_minutes) + session.flush() + return task + + +def ingest_alpha_factors(session: Session, task: ComputeTask) -> list[AlphaFactor]: + """成功回传的挖掘结果里,只保存公式非空且绝对 IC 达到门槛的因子。""" + if task.task_type != "alpha_mining" or task.status != TaskStatus.COMPLETED or not task.success: + return [] + if task.node_id is None: + return [] + payload = task.payload or {} + saved: list[AlphaFactor] = [] + for item in _factor_items(task.result or {}): + formula = str(item.get("formula") or item.get("alpha") or "").strip() + ic = _as_float(item.get("ic")) + if not formula or ic is None or abs(ic) < MIN_ABS_IC: + continue + symbol = item.get("symbol") or payload.get("symbol") + seed = payload.get("seed") + row = AlphaFactor( + task_id=task.id, + node_id=task.node_id, + formula=formula, + ic=ic, + symbol=str(symbol) if symbol else None, + seed=None if seed is None else str(seed), + created_at=task.responded_at or utcnow(), + ) + session.add(row) + saved.append(row) + if saved: + session.flush() + return saved + + +def _create_alpha_batch(session: Session, moment: datetime) -> list[ComputeTask]: + seed = secrets.randbelow(2**31) + rng = random.Random(seed) + batch_id = uuid.uuid4().hex[:12] + combos = _operator_combos(rng, SLICE_COUNT) + symbols = rng.sample(SYMBOLS, k=SLICE_COUNT) + deadline = moment + timedelta(days=QUEUE_HOLD_DAYS) + tasks: list[ComputeTask] = [] + for combo, symbol in zip(combos, symbols): + task = ComputeTask( + id=str(uuid.uuid4()), + node_id=None, + task_type="alpha_mining", + payload={ + "batch_id": batch_id, + "seed": seed, + "operators": combo, + "symbol": symbol, + "nonce": secrets.token_hex(16), + }, + status=TaskStatus.PENDING, + push_status=PushStatus.QUEUED, + dispatched_at=moment, + deadline_at=deadline, + ) + session.add(task) + tasks.append(task) + session.flush() + return tasks + + +def _operator_combos(rng: random.Random, count: int) -> list[list[str]]: + combos: list[list[str]] = [] + seen: set[tuple[str, ...]] = set() + while len(combos) < count: + size = 2 if rng.random() < 0.7 else 3 + combo = tuple(sorted(rng.sample(OPERATORS, k=size))) + if combo in seen: + continue + seen.add(combo) + combos.append(list(combo)) + return combos + + +def _factor_items(result: dict) -> list[dict]: + factors = result.get("factors") + if isinstance(factors, list): + return [item for item in factors if isinstance(item, dict)] + if any(key in result for key in ("formula", "alpha", "ic")): + return [result] + return [] + + +def _as_utc(value: datetime | str | None) -> datetime | None: + if value is None: + return None + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _as_float(value: object) -> float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None diff --git a/app/services/tasks.py b/app/services/tasks.py index 5751c07..ea8ee69 100644 --- a/app/services/tasks.py +++ b/app/services/tasks.py @@ -24,6 +24,7 @@ from app.clock import utcnow from app.config import settings from app.errors import AppError from app.models import ComputeNode, ComputeTask, NodeStatus, PushStatus, TaskStatus +from app.services.alpha_queue import ingest_alpha_factors from app.services.common import get_node_by_sn logger = logging.getLogger(__name__) @@ -125,8 +126,15 @@ def dispatch_random_tasks(session: Session, now: datetime | None = None) -> list """向当前在线、授权仍有效、且没有未完成任务的节点随机下发一轮任务。""" moment = now or utcnow() expire_overdue_tasks(session, moment) + # 未绑定的挖掘切片不算占用节点。已派发的挖掘任务单独排队,不挡住模拟任务。 pending_node_ids = set( - session.scalars(select(ComputeTask.node_id).where(ComputeTask.status.in_(_BUSY))).all() + session.scalars( + select(ComputeTask.node_id).where( + ComputeTask.node_id.is_not(None), + ComputeTask.status.in_(_BUSY), + ComputeTask.task_type != "alpha_mining", + ) + ).all() ) candidates = [ node @@ -174,15 +182,16 @@ def list_pending_tasks(session: Session, sn: str, now: datetime | None = None) - node = get_node_by_sn(session, sn.strip()) tasks = session.scalars( select(ComputeTask) - .where(ComputeTask.node_id == node.id, ComputeTask.status == TaskStatus.PENDING) - .order_by(ComputeTask.dispatched_at) + .where(ComputeTask.node_id == node.id, ComputeTask.status.in_(_BUSY)) + .order_by(ComputeTask.dispatched_at, ComputeTask.id) ).all() visible: list[ComputeTask] = [] for task in tasks: if task.deadline_at < moment: task.status = TaskStatus.EXPIRED continue - task.status = TaskStatus.PROCESSING + if task.status == TaskStatus.PENDING: + task.status = TaskStatus.PROCESSING visible.append(task) session.flush() return visible @@ -221,6 +230,8 @@ def report_task( task.result = result or None task.status = TaskStatus.COMPLETED if success else TaskStatus.FAILED session.flush() + if task.status == TaskStatus.COMPLETED and task.task_type == "alpha_mining": + ingest_alpha_factors(session, task) return task @@ -228,6 +239,7 @@ def expire_overdue_tasks(session: Session, now: datetime | None = None) -> int: moment = now or utcnow() tasks = session.scalars( select(ComputeTask).where( + ComputeTask.node_id.is_not(None), ComputeTask.status.in_(_BUSY), ComputeTask.deadline_at < moment, ) diff --git a/frontend/src/views/AdminOverview.vue b/frontend/src/views/AdminOverview.vue index fcd26f2..4310740 100644 --- a/frontend/src/views/AdminOverview.vue +++ b/frontend/src/views/AdminOverview.vue @@ -483,7 +483,7 @@ onUnmounted(() => window.clearInterval(timer)); {{ row.task_id.slice(0, 8) }} {{ taskTypeText(row.task_type) }} - {{ row.display_node_id || "—" }} · {{ row.target_node_sn }} + {{ row.target_node_sn ? `${row.display_node_id || "—"} · ${row.target_node_sn}` : "排队中" }} {{ taskStatusText[row.status] || row.status }} {{ when(row.responded_at) }} {{ row.result_preview || "—" }} diff --git a/tests/test_core_flow.py b/tests/test_core_flow.py index 40b6c7a..0d2ad3d 100644 --- a/tests/test_core_flow.py +++ b/tests/test_core_flow.py @@ -21,7 +21,7 @@ os.environ["ADMIN_PASSWORD"] = "test-admin-pass" os.environ["ADMIN_WALLET"] = "admin-root" from fastapi.testclient import TestClient -from sqlalchemy import select +from sqlalchemy import create_engine, select, text from app.clock import utcnow from app.database import Base, SessionLocal, engine @@ -29,6 +29,7 @@ from app.main import app from app.jobs.daily_yield import run_daily_yield_cron from app.money import money from app.models import ( + AlphaFactor, ComputeNode, ComputeTask, IPWhitelist, @@ -42,7 +43,9 @@ from app.models import ( from app.services.heartbeat import mark_stale_nodes from app.services.datacenter import FINGERPRINT_CLASH from app.services.ip_guard import DATACENTER_BATCH_MESSAGE -from app.services.tasks import dispatch_random_tasks +from app.schema_upgrade import _relax_task_node_id +from app.services.alpha_queue import maybe_generate_alpha_batch +from app.services.tasks import dispatch_random_tasks, expire_overdue_tasks class CoreFlowTest(unittest.TestCase): @@ -197,7 +200,7 @@ class CoreFlowTest(unittest.TestCase): pending = self.client.get("/api/v1/node/NODE-T/tasks") self.assertEqual(pending.status_code, 200, pending.text) - self.assertEqual(pending.json()[0]["task_id"], task_id) + self.assertTrue(any(row["task_id"] == task_id for row in pending.json())) rejected = self.client.post( "/api/v1/node/task/report", @@ -763,6 +766,187 @@ class CoreFlowTest(unittest.TestCase): self.assertEqual(logged.json()["role"], "admin") return {"Authorization": f"Bearer {logged.json()['access_token']}"} + def test_alpha_queue_generates_assigns_and_keeps_high_ic(self) -> None: + self._register("0xalpha", "NODE-ALPHA", "100") + start = utcnow() + with SessionLocal() as session: + first = maybe_generate_alpha_batch(session, now=start) + session.commit() + self.assertEqual(len(first), 3) + self.assertEqual(len({task.payload["batch_id"] for task in first}), 1) + for task in first: + self.assertIsNone(task.node_id) + self.assertEqual(task.status, TaskStatus.PENDING) + self.assertEqual(task.task_type, "alpha_mining") + self.assertIsInstance(task.payload["seed"], int) + self.assertTrue(task.payload["operators"]) + self.assertTrue(task.payload["symbol"]) + self.assertGreaterEqual(len(task.payload["nonce"]), 8) + quiet = maybe_generate_alpha_batch(session, now=start + timedelta(hours=1)) + self.assertEqual(quiet, []) + later = maybe_generate_alpha_batch(session, now=start + timedelta(hours=2)) + session.commit() + self.assertEqual(len(later), 3) + self.assertNotEqual(later[0].payload["batch_id"], first[0].payload["batch_id"]) + queued = session.get(ComputeTask, first[0].id) + queued.deadline_at = start - timedelta(minutes=1) + session.commit() + self.assertEqual(expire_overdue_tasks(session, start), 0) + self.assertEqual(session.get(ComputeTask, first[0].id).status, TaskStatus.PENDING) + + listed = self.client.get("/api/v1/admin/tasks/list", headers=self._admin_headers()) + self.assertEqual(listed.status_code, 200, listed.text) + queued_row = next(row for row in listed.json() if row["task_id"] == first[0].id) + self.assertEqual(queued_row["target_node_sn"], "") + self.assertEqual(queued_row["display_node_id"], 0) + + beat = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"}) + self.assertEqual(beat.status_code, 200, beat.text) + assigned = beat.json()["assigned_task"] + self.assertIsNotNone(assigned) + self.assertEqual(assigned["status"], "processing") + self.assertEqual(assigned["task_type"], "alpha_mining") + again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"}) + self.assertIsNone(again.json()["assigned_task"]) + with SessionLocal() as session: + node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-ALPHA")) + holding = session.scalars( + select(ComputeTask).where( + ComputeTask.node_id == node.id, + ComputeTask.task_type == "alpha_mining", + ComputeTask.status == TaskStatus.PROCESSING, + ) + ).all() + self.assertEqual(len(holding), 1) + + high = self.client.post( + "/api/v1/node/task/report", + json={ + "sn": "NODE-ALPHA", + "task_id": assigned["task_id"], + "success": True, + "nonce": assigned["payload"]["nonce"], + "result": { + "factors": [ + {"formula": "rank(delta(close, 1))", "ic": 0.08}, + {"formula": "scale(ts_std(close, 5))", "ic": 0.01}, + {"alpha": "", "ic": 0.2}, + ] + }, + }, + ) + self.assertEqual(high.status_code, 200, high.text) + self.assertEqual(high.json()["status"], "completed") + with SessionLocal() as session: + rows = session.scalars(select(AlphaFactor)).all() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].formula, "rank(delta(close, 1))") + self.assertAlmostEqual(rows[0].ic, 0.08) + self.assertEqual(rows[0].symbol, assigned["payload"]["symbol"]) + + follow = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"}) + second = follow.json()["assigned_task"] + self.assertIsNotNone(second) + self.assertNotEqual(second["task_id"], assigned["task_id"]) + low = self.client.post( + "/api/v1/node/task/report", + json={ + "sn": "NODE-ALPHA", + "task_id": second["task_id"], + "success": True, + "nonce": second["payload"]["nonce"], + "result": {"formula": "ts_mean(close, 3)", "ic": 0.01}, + }, + ) + self.assertEqual(low.status_code, 200, low.text) + self.assertEqual(low.json()["status"], "completed") + with SessionLocal() as session: + self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1) + self.assertEqual(session.get(ComputeTask, second["task_id"]).status, TaskStatus.COMPLETED) + + follow_again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"}) + third = follow_again.json()["assigned_task"] + self.assertIsNotNone(third) + visible = self.client.get("/api/v1/node/NODE-ALPHA/tasks") + self.assertEqual( + [row for row in visible.json() if row["task_id"] == third["task_id"]][0]["status"], + "processing", + ) + failed = self.client.post( + "/api/v1/node/task/report", + json={ + "sn": "NODE-ALPHA", + "task_id": third["task_id"], + "success": False, + "nonce": third["payload"]["nonce"], + "result": {"formula": "rank(close)", "ic": 0.2}, + }, + ) + self.assertEqual(failed.status_code, 200, failed.text) + self.assertEqual(failed.json()["status"], "failed") + with SessionLocal() as session: + self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1) + + def test_old_task_table_accepts_unassigned_rows(self) -> None: + path = Path(tempfile.gettempdir()) / "hypernode_relax_node.db" + if path.exists(): + path.unlink() + other = create_engine("sqlite:///" + path.as_posix()) + with other.begin() as conn: + conn.execute(text("CREATE TABLE compute_nodes (id INTEGER PRIMARY KEY)")) + conn.execute(text("INSERT INTO compute_nodes (id) VALUES (1)")) + conn.execute( + text( + """ + CREATE TABLE compute_tasks ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + node_id INTEGER NOT NULL, + task_type VARCHAR(32) NOT NULL, + payload JSON NOT NULL, + status VARCHAR(16) NOT NULL, + push_status VARCHAR(16) NOT NULL, + push_error VARCHAR(255), + dispatched_at DATETIME NOT NULL, + deadline_at DATETIME NOT NULL, + responded_at DATETIME, + success BOOLEAN, + result JSON + ) + """ + ) + ) + conn.execute( + text( + """ + INSERT INTO compute_tasks ( + id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at + ) VALUES ( + 'kept-task', 1, 'simulated_compute', '{}', 'pending', 'queued', + '2026-10-01 00:00:00', '2026-10-01 00:10:00' + ) + """ + ) + ) + _relax_task_node_id(other) + with other.begin() as conn: + conn.execute( + text( + """ + INSERT INTO compute_tasks ( + id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at + ) VALUES ( + 'queued-task', NULL, 'alpha_mining', '{}', 'pending', 'queued', + '2026-10-01 00:00:00', '2026-10-08 00:00:00' + ) + """ + ) + ) + kept = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'kept-task'")).scalar() + queued = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'queued-task'")).scalar() + other.dispose() + self.assertEqual(kept, 1) + self.assertIsNone(queued) + def _register(self, wallet: str, sn: str, amount: str, headers: dict[str, str] | None = None) -> None: user = self.client.post("/api/v1/users", json={"wallet_address": wallet}) self.assertEqual(user.status_code, 200, user.text) diff --git a/web/assets/index-Lxi5Kb1N.js b/web/assets/index-De4QqK0A.js similarity index 96% rename from web/assets/index-Lxi5Kb1N.js rename to web/assets/index-De4QqK0A.js index 02c9b17..4ebc1c1 100644 --- a/web/assets/index-Lxi5Kb1N.js +++ b/web/assets/index-De4QqK0A.js @@ -37,4 +37,4 @@ }`},{id:"stress_test",label:"简易压测",sample:`{ "duration_seconds": 60, "matrix": 4096 -}`}],E=Z({task_type:"alpha_mining",target_node_sn:"",payloadText:b[0].sample}),P=Z(!1),G={pending:"待领取",processing:"执行中",completed:"已完成",failed:"失败",expired:"已过期"},j=Z({ip_address:"",note:""}),O=Z(""),k=Z(""),U=Z(!1),F=Z(null);let w=0,te=0;const R=je(()=>{const v=String(n.params.tab||"");return t.some(m=>m.id===v)?v:""});function Be(v){return v.gpu_generation==="40"?"40 系":v.gpu_generation==="30"?"30 系":"10 系及以下"}function Ke(v){const m=b.find(D=>D.id===v);return m?m.label:v==="simulated_compute"?"模拟计算":v}function tt(){if(P.value)return;const v=b.find(m=>m.id===E.value.task_type);E.value.payloadText=v?v.sample:"{}"}function pt(v){return v.gpu_model?v.vram_gb?`${v.gpu_model} · ${v.vram_gb} GB`:v.gpu_model:"未上报"}async function Ue(){l.value=await ae("/admin/summary")}async function Tt(v,m=!1){const g={orders:async()=>{u.value=await ae("/admin/orders")},nodes:async()=>{i.value=await ae("/admin/nodes")},tasks:async()=>{const[f,p]=await Promise.all([ae("/admin/tasks/list"),ae("/admin/nodes")]);y.value=f,i.value=p},whitelist:async()=>{c.value=await ae("/admin/ip-whitelist")},"datacenter-apps":async()=>{h.value=await ae("/admin/datacenter-applications")},withdrawals:async()=>{a.value=await ae("/admin/withdrawals")},users:async()=>{r.value=await ae("/admin/users")}}[v];if(!g)return;const W=++te;m||(U.value=!0);try{await g()}finally{W===te&&(U.value=!1)}}async function _e(){await Ue(),R.value&&await Tt(R.value,!0)}async function ge(v){O.value="",k.value="";try{const m=await ae(`/admin/orders/${v.id}/approve`,{method:"POST"});k.value=`订单 #${v.id} 已开通,节点 ${m.display_node_id||m.sn}`,await _e()}catch(m){O.value=m.message}}async function ne(v){O.value="",k.value="";try{await ae(`/admin/orders/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"支付凭证未通过"})}),k.value=`订单 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function X(v){const m=(d.value[v.id]||"").trim();if(!m){O.value="请先填写链上哈希";return}O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/confirm`,{method:"POST",body:JSON.stringify({tx_hash:m})}),k.value=`提现 #${v.id} 已打款`,await _e()}catch(D){O.value=D.message}}async function Je(v){O.value="",k.value="";const m=!v.is_whitelist;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/whitelist`,{method:"POST",body:JSON.stringify({is_whitelist:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已加入白名单`:`节点 ${v.display_node_id||v.sn} 已取消白名单`,await _e()}catch(D){O.value=D.message}}async function ze(v,m){O.value="",k.value="",F.value=null;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/ban`,{method:"POST",body:JSON.stringify({is_banned:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已封禁`:`节点 ${v.display_node_id||v.sn} 已解除封禁`,await _e()}catch(D){O.value=D.message}}async function Ge(){O.value="",k.value="";let v;try{v=JSON.parse(E.value.payloadText)}catch{O.value="Payload 不是合法 JSON";return}if(!v||Array.isArray(v)||typeof v!="object"){O.value="Payload 需要是 JSON 对象";return}try{const m=await ae("/admin/tasks/create",{method:"POST",body:JSON.stringify({task_type:E.value.task_type,target_node_sn:E.value.target_node_sn||null,payload:v})});k.value=`已下发 ${m.length} 个任务`,await _e()}catch(m){O.value=m.message}}async function Se(){O.value="",k.value="";try{await ae("/admin/ip-whitelist",{method:"POST",body:JSON.stringify({ip_address:j.value.ip_address.trim(),note:j.value.note.trim()})}),j.value={ip_address:"",note:""},k.value="白名单 IP 已添加",await _e()}catch(v){O.value=v.message}}async function At(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/approve`,{method:"POST"}),k.value=`机房申请 #${v.id} 已通过,网段 ${v.subnet_ip} 已加入白名单`,await _e()}catch(m){O.value=m.message}}async function Rt(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"未达到机房门槛"})}),k.value=`机房申请 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function ht(v){O.value="",k.value="";try{await ae(`/admin/ip-whitelist/${v.id}`,{method:"DELETE"}),k.value=`${v.ip_address} 已移出白名单`,await _e()}catch(m){O.value=m.message}}async function Ve(v){O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"管理后台拒绝"})}),k.value=`提现 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}return en(R,async v=>{if(!v){s.replace("/admin/orders");return}try{await Tt(v)}catch(m){U.value=!1,O.value=m.message}},{immediate:!0}),Pn(async()=>{try{await Ue()}catch(v){O.value=v.message}w=window.setInterval(()=>{_e().catch(v=>{O.value=v.message})},1e4)}),In(()=>window.clearInterval(w)),(v,m)=>{const D=cs("router-link");return I(),qt(Ii,{eyebrow:"管理后台",title:"全网概览"},{default:Dt(()=>[O.value?(I(),N("p",Rf,C(O.value),1)):Y("",!0),k.value?(I(),N("p",Of,C(k.value),1)):Y("",!0),l.value?(I(),N("section",Pf,[o("article",If,[m[8]||(m[8]=o("span",null,"用户",-1)),o("strong",null,C(l.value.users),1),m[9]||(m[9]=o("em",null,"已注册钱包",-1))]),o("article",Nf,[m[10]||(m[10]=o("span",null,"全网节点",-1)),o("strong",null,C(l.value.nodes),1),o("em",null,"在线 "+C(l.value.online)+" · 离线 "+C(l.value.offline),1)]),o("article",kf,[m[11]||(m[11]=o("span",null,"总产出",-1)),o("strong",null,C(q(Ce)(l.value.settled_points)),1),o("em",null,"授权 "+C(q(Ce)(l.value.authorized_amount)),1)]),o("article",Df,[m[12]||(m[12]=o("span",null,"待审核",-1)),o("strong",null,C(q(Ce)(l.value.pending_withdrawals)),1),m[13]||(m[13]=o("em",null,"占用中的提现",-1))])])):Y("",!0),o("nav",$f,[(I(),N(ce,null,Ne(t,g=>be(D,{key:g.id,role:"tab",to:`/admin/${g.id}`,"aria-selected":R.value===g.id,class:he({on:R.value===g.id})},{default:Dt(()=>[ee(C(g.label),1)]),_:2},1032,["to","aria-selected","class"])),64))]),F.value?(I(),N("p",Mf,[ee(" 确认封禁节点 "+C(F.value.display_node_id||F.value.sn)+"?封禁后心跳会被拒绝。 ",1),o("button",{class:"danger",type:"button",onClick:m[0]||(m[0]=g=>ze(F.value,!0))},"确认封禁"),o("button",{class:"texty",type:"button",onClick:m[1]||(m[1]=g=>F.value=null)},"取消")])):Y("",!0),o("section",Vf,[U.value?(I(),N("p",Lf,"正在读取…")):R.value==="orders"?(I(),N("div",Uf,[o("table",null,[m[15]||(m[15]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"金额"),o("th",null,"TxID"),o("th",null,"状态"),o("th",null,"节点"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(u.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Ff,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",jf,C(g.tx_id),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,C(g.display_node_id||g.sn||"—"),1),g.status==="pending"?(I(),N("td",Hf,[o("button",{class:"gold",type:"button",onClick:W=>ge(g)},"审核开通",8,Bf),o("button",{class:"texty",type:"button",onClick:W=>ne(g)},"拒绝",8,Kf)])):(I(),N("td",Gf))]))),128)),u.value.length?Y("",!0):(I(),N("tr",Wf,[...m[14]||(m[14]=[o("td",{colspan:"7",class:"empty"},"还没有授权订单。",-1)])]))])])])):R.value==="nodes"?(I(),N("section",qf,[(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("article",{key:g.sn,class:"plate"},[o("header",null,[o("strong",null,C(g.display_node_id||g.sn),1),o("span",{class:he(["pill",g.is_banned?"rejected":g.status])},[m[16]||(m[16]=o("i",null,null,-1)),ee(C(g.is_banned?"已封禁":q(wt)[g.status]),1)],2)]),o("div",Jf,[g.is_whitelist?(I(),N("span",zf,"白名单")):Y("",!0),o("label",Yf,[o("input",{type:"checkbox",checked:g.is_whitelist,onChange:W=>Je(g)},null,40,Qf),m[17]||(m[17]=o("span",null,null,-1)),m[18]||(m[18]=ee(" 白名单 ",-1))]),g.is_banned?(I(),N("button",{key:1,class:"texty",type:"button",onClick:W=>ze(g,!1)},"解除封禁",8,Xf)):(I(),N("button",{key:2,class:"danger",type:"button",onClick:W=>F.value=g},"封禁",8,Zf))]),g.genesis?(I(),N("p",ed,"[Genesis Special Node - 30 Days Recovery]")):Y("",!0),g.genesis?(I(),N("p",td,"创世特权节点 - 30天回本")):Y("",!0),o("p",nd,C(g.wallet_address),1),o("p",sd,C(g.sn),1),o("p",ld,[o("b",{style:Rn({width:q(Xn)(g.online_rate)})},null,4),ee(C(q(Xn)(g.online_rate))+" 今日在线率",1)]),o("dl",null,[o("div",null,[m[19]||(m[19]=o("dt",null,"显卡",-1)),o("dd",null,C(pt(g)),1)]),o("div",null,[m[20]||(m[20]=o("dt",null,"CPU",-1)),o("dd",null,C(g.cpu_count?`${g.cpu_count} 核`:"—"),1)]),o("div",null,[m[21]||(m[21]=o("dt",null,"内存",-1)),o("dd",null,C(g.memory_gb?`${g.memory_gb} GB`:"—"),1)]),o("div",null,[m[22]||(m[22]=o("dt",null,"权重",-1)),o("dd",null,C(g.hardware_weight),1)]),o("div",null,[m[23]||(m[23]=o("dt",null,"世代",-1)),o("dd",null,C(Be(g)),1)]),o("div",null,[m[24]||(m[24]=o("dt",null,"绑定 IP",-1)),o("dd",null,C(g.client_ip||"—"),1)]),o("div",null,[m[25]||(m[25]=o("dt",null,"今日积分",-1)),o("dd",null,C(q(Ce)(g.today_points)),1)]),o("div",null,[m[26]||(m[26]=o("dt",null,"心跳",-1)),o("dd",null,C(q(Xt)(g.last_heartbeat_at)),1)])])]))),128)),i.value.length?Y("",!0):(I(),N("p",rd,"还没有节点。"))])):R.value==="tasks"?(I(),N("div",id,[o("form",{class:"panel",onSubmit:Gt(Ge,["prevent"])},[m[31]||(m[31]=o("h2",null,"发布任务",-1)),m[32]||(m[32]=o("p",{class:"sub"},"指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。",-1)),o("div",od,[o("label",null,[m[27]||(m[27]=ee("任务类型 ",-1)),we(o("select",{"onUpdate:modelValue":m[2]||(m[2]=g=>E.value.task_type=g),onChange:tt},[(I(),N(ce,null,Ne(b,g=>o("option",{key:g.id,value:g.id},C(g.label),9,ad)),64))],544),[[Hs,E.value.task_type]])]),o("label",null,[m[29]||(m[29]=ee("目标节点 ",-1)),we(o("select",{"onUpdate:modelValue":m[3]||(m[3]=g=>E.value.target_node_sn=g)},[m[28]||(m[28]=o("option",{value:""},"全部在线节点",-1)),(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("option",{key:g.sn,value:g.sn},C(g.display_node_id||g.sn)+" · "+C(g.sn),9,ud))),128))],512),[[Hs,E.value.target_node_sn]])]),o("label",cd,[m[30]||(m[30]=ee("Payload ",-1)),we(o("textarea",{"onUpdate:modelValue":m[4]||(m[4]=g=>E.value.payloadText=g),required:"",spellcheck:"false",onInput:m[5]||(m[5]=g=>P.value=!0)},null,544),[[De,E.value.payloadText]])])]),m[33]||(m[33]=o("button",{class:"gold",type:"submit"},"立即下发任务",-1))],32),o("div",fd,[o("table",null,[m[35]||(m[35]=o("thead",null,[o("tr",null,[o("th",null,"任务"),o("th",null,"类型"),o("th",null,"节点"),o("th",null,"状态"),o("th",null,"完成时间"),o("th",null,"结果")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(y.value,g=>(I(),N("tr",{key:g.task_id},[o("td",{class:"mono",title:g.task_id},C(g.task_id.slice(0,8)),9,dd),o("td",null,C(Ke(g.task_type)),1),o("td",null,C(g.display_node_id||"—")+" · "+C(g.target_node_sn),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(G[g.status]||g.status),3)]),o("td",null,C(q(Xt)(g.responded_at)),1),o("td",pd,C(g.result_preview||"—"),1)]))),128)),y.value.length?Y("",!0):(I(),N("tr",hd,[...m[34]||(m[34]=[o("td",{colspan:"6",class:"empty"},"还没有任务。",-1)])]))])])])])):R.value==="whitelist"?(I(),N("section",md,[m[41]||(m[41]=o("p",{class:"sub"},"可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。",-1)),o("form",{class:"ip-form",onSubmit:Gt(Se,["prevent"])},[o("label",null,[m[36]||(m[36]=ee("豁免 IP / 网段",-1)),we(o("input",{"onUpdate:modelValue":m[6]||(m[6]=g=>j.value.ip_address=g),required:"",placeholder:"203.0.113.0/24"},null,512),[[De,j.value.ip_address]])]),o("label",null,[m[37]||(m[37]=ee("备注",-1)),we(o("input",{"onUpdate:modelValue":m[7]||(m[7]=g=>j.value.note=g),placeholder:"自营机房出口"},null,512),[[De,j.value.note]])]),m[38]||(m[38]=o("button",{class:"gold",type:"submit"},"添加豁免 IP",-1))],32),o("div",gd,[o("table",null,[m[40]||(m[40]=o("thead",null,[o("tr",null,[o("th",null,"IP"),o("th",null,"备注"),o("th",null,"添加时间"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(c.value,g=>(I(),N("tr",{key:g.id},[o("td",vd,C(g.ip_address),1),o("td",null,C(g.note||"—"),1),o("td",null,C(q(Xt)(g.created_at)),1),o("td",_d,[o("button",{class:"texty",type:"button",onClick:W=>ht(g)},"移除",8,yd)])]))),128)),c.value.length?Y("",!0):(I(),N("tr",bd,[...m[39]||(m[39]=[o("td",{colspan:"4",class:"empty"},"还没有豁免 IP。",-1)])]))])])])])):R.value==="datacenter-apps"?(I(),N("div",wd,[o("table",null,[m[43]||(m[43]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"联系方式"),o("th",null,"节点数"),o("th",null,"网段"),o("th",null,"显卡"),o("th",null,"证明"),o("th",null,"状态"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(h.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Sd,C(g.wallet_address),1),o("td",null,C(g.contact),1),o("td",null,[ee(C(g.node_count)+" ",1),o("span",{class:he(["pill",g.node_count>=5?"pending":"rejected"])},C(g.node_count>=5?"≥ 5 台":"不足 5 台"),3)]),o("td",xd,C(g.subnet_ip),1),o("td",null,C(g.gpu_generation)+" 系",1),o("td",null,C(g.hardware_proof),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),g.status==="pending"?(I(),N("td",Ed,[o("button",{class:"gold",type:"button",onClick:W=>At(g)},"审批通过",8,Cd),o("button",{class:"texty",type:"button",onClick:W=>Rt(g)},"拒绝",8,Td)])):(I(),N("td",Ad))]))),128)),h.value.length?Y("",!0):(I(),N("tr",Rd,[...m[42]||(m[42]=[o("td",{colspan:"9",class:"empty"},"还没有机房申请。",-1)])]))])])])):R.value==="withdrawals"?(I(),N("div",Od,[m[46]||(m[46]=o("p",{class:"sub tab-note"},"折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。",-1)),o("div",Pd,[o("table",null,[m[45]||(m[45]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"USDT 地址"),o("th",null,"积分"),o("th",null,"折算金额"),o("th",null,"状态"),o("th",null,"链上哈希"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(a.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Id,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",null,C(q(Ni)(g.amount))+" USDT",1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,[g.status==="pending"?we((I(),N("input",{key:0,"onUpdate:modelValue":W=>d.value[g.id]=W,placeholder:"交易哈希"},null,8,Nd)),[[De,d.value[g.id]]]):(I(),N("span",kd,C(g.tx_hash||"—"),1))]),g.status==="pending"?(I(),N("td",Dd,[o("button",{class:"gold",type:"button",onClick:W=>X(g)},"确认打款",8,$d),o("button",{class:"texty",type:"button",onClick:W=>Ve(g)},"拒绝",8,Md)])):(I(),N("td",Vd))]))),128)),a.value.length?Y("",!0):(I(),N("tr",Ld,[...m[44]||(m[44]=[o("td",{colspan:"7",class:"empty"},"还没有提现申请。",-1)])]))])])])])):R.value==="users"?(I(),N("div",Ud,[o("table",null,[m[48]||(m[48]=o("thead",null,[o("tr",null,[o("th",null,"钱包"),o("th",null,"用户名"),o("th",null,"角色"),o("th",null,"节点"),o("th",null,"可用积分"),o("th",null,"注册")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(r.value,g=>(I(),N("tr",{key:g.id},[o("td",Fd,C(g.wallet_address),1),o("td",null,C(g.username||"—"),1),o("td",null,C(q(wt)[g.role]),1),o("td",null,C(g.node_count),1),o("td",null,C(q(Ce)(g.available_points)),1),o("td",null,C(q(Xt)(g.created_at)),1)]))),128)),r.value.length?Y("",!0):(I(),N("tr",jd,[...m[47]||(m[47]=[o("td",{colspan:"6",class:"empty"},"还没有用户。",-1)])]))])])])):Y("",!0)])]),_:1})}}},ki=vc({history:zu(),routes:[{path:"/",redirect:()=>hl().role==="admin"?"/admin":"/dashboard"},{path:"/login",component:Dc},{path:"/dashboard",redirect:"/dashboard/nodes"},{path:"/dashboard/:tab",component:Af,meta:{auth:!0}},{path:"/admin",redirect:"/admin/orders"},{path:"/admin/:tab",component:Hd,meta:{auth:!0,admin:!0}}]});ki.beforeEach(e=>{const t=hl();return e.meta.auth&&!t.token?"/login":e.meta.admin&&t.role!=="admin"?"/dashboard":e.path==="/login"&&t.token?t.role==="admin"?"/admin":"/dashboard":!0});ru(fu).use(ki).mount("#app"); +}`}],E=Z({task_type:"alpha_mining",target_node_sn:"",payloadText:b[0].sample}),P=Z(!1),G={pending:"待领取",processing:"执行中",completed:"已完成",failed:"失败",expired:"已过期"},j=Z({ip_address:"",note:""}),O=Z(""),k=Z(""),U=Z(!1),F=Z(null);let w=0,te=0;const R=je(()=>{const v=String(n.params.tab||"");return t.some(m=>m.id===v)?v:""});function Be(v){return v.gpu_generation==="40"?"40 系":v.gpu_generation==="30"?"30 系":"10 系及以下"}function Ke(v){const m=b.find(D=>D.id===v);return m?m.label:v==="simulated_compute"?"模拟计算":v}function tt(){if(P.value)return;const v=b.find(m=>m.id===E.value.task_type);E.value.payloadText=v?v.sample:"{}"}function pt(v){return v.gpu_model?v.vram_gb?`${v.gpu_model} · ${v.vram_gb} GB`:v.gpu_model:"未上报"}async function Ue(){l.value=await ae("/admin/summary")}async function Tt(v,m=!1){const g={orders:async()=>{u.value=await ae("/admin/orders")},nodes:async()=>{i.value=await ae("/admin/nodes")},tasks:async()=>{const[f,p]=await Promise.all([ae("/admin/tasks/list"),ae("/admin/nodes")]);y.value=f,i.value=p},whitelist:async()=>{c.value=await ae("/admin/ip-whitelist")},"datacenter-apps":async()=>{h.value=await ae("/admin/datacenter-applications")},withdrawals:async()=>{a.value=await ae("/admin/withdrawals")},users:async()=>{r.value=await ae("/admin/users")}}[v];if(!g)return;const W=++te;m||(U.value=!0);try{await g()}finally{W===te&&(U.value=!1)}}async function _e(){await Ue(),R.value&&await Tt(R.value,!0)}async function ge(v){O.value="",k.value="";try{const m=await ae(`/admin/orders/${v.id}/approve`,{method:"POST"});k.value=`订单 #${v.id} 已开通,节点 ${m.display_node_id||m.sn}`,await _e()}catch(m){O.value=m.message}}async function ne(v){O.value="",k.value="";try{await ae(`/admin/orders/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"支付凭证未通过"})}),k.value=`订单 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function X(v){const m=(d.value[v.id]||"").trim();if(!m){O.value="请先填写链上哈希";return}O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/confirm`,{method:"POST",body:JSON.stringify({tx_hash:m})}),k.value=`提现 #${v.id} 已打款`,await _e()}catch(D){O.value=D.message}}async function Je(v){O.value="",k.value="";const m=!v.is_whitelist;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/whitelist`,{method:"POST",body:JSON.stringify({is_whitelist:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已加入白名单`:`节点 ${v.display_node_id||v.sn} 已取消白名单`,await _e()}catch(D){O.value=D.message}}async function ze(v,m){O.value="",k.value="",F.value=null;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/ban`,{method:"POST",body:JSON.stringify({is_banned:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已封禁`:`节点 ${v.display_node_id||v.sn} 已解除封禁`,await _e()}catch(D){O.value=D.message}}async function Ge(){O.value="",k.value="";let v;try{v=JSON.parse(E.value.payloadText)}catch{O.value="Payload 不是合法 JSON";return}if(!v||Array.isArray(v)||typeof v!="object"){O.value="Payload 需要是 JSON 对象";return}try{const m=await ae("/admin/tasks/create",{method:"POST",body:JSON.stringify({task_type:E.value.task_type,target_node_sn:E.value.target_node_sn||null,payload:v})});k.value=`已下发 ${m.length} 个任务`,await _e()}catch(m){O.value=m.message}}async function Se(){O.value="",k.value="";try{await ae("/admin/ip-whitelist",{method:"POST",body:JSON.stringify({ip_address:j.value.ip_address.trim(),note:j.value.note.trim()})}),j.value={ip_address:"",note:""},k.value="白名单 IP 已添加",await _e()}catch(v){O.value=v.message}}async function At(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/approve`,{method:"POST"}),k.value=`机房申请 #${v.id} 已通过,网段 ${v.subnet_ip} 已加入白名单`,await _e()}catch(m){O.value=m.message}}async function Rt(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"未达到机房门槛"})}),k.value=`机房申请 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function ht(v){O.value="",k.value="";try{await ae(`/admin/ip-whitelist/${v.id}`,{method:"DELETE"}),k.value=`${v.ip_address} 已移出白名单`,await _e()}catch(m){O.value=m.message}}async function Ve(v){O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"管理后台拒绝"})}),k.value=`提现 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}return en(R,async v=>{if(!v){s.replace("/admin/orders");return}try{await Tt(v)}catch(m){U.value=!1,O.value=m.message}},{immediate:!0}),Pn(async()=>{try{await Ue()}catch(v){O.value=v.message}w=window.setInterval(()=>{_e().catch(v=>{O.value=v.message})},1e4)}),In(()=>window.clearInterval(w)),(v,m)=>{const D=cs("router-link");return I(),qt(Ii,{eyebrow:"管理后台",title:"全网概览"},{default:Dt(()=>[O.value?(I(),N("p",Rf,C(O.value),1)):Y("",!0),k.value?(I(),N("p",Of,C(k.value),1)):Y("",!0),l.value?(I(),N("section",Pf,[o("article",If,[m[8]||(m[8]=o("span",null,"用户",-1)),o("strong",null,C(l.value.users),1),m[9]||(m[9]=o("em",null,"已注册钱包",-1))]),o("article",Nf,[m[10]||(m[10]=o("span",null,"全网节点",-1)),o("strong",null,C(l.value.nodes),1),o("em",null,"在线 "+C(l.value.online)+" · 离线 "+C(l.value.offline),1)]),o("article",kf,[m[11]||(m[11]=o("span",null,"总产出",-1)),o("strong",null,C(q(Ce)(l.value.settled_points)),1),o("em",null,"授权 "+C(q(Ce)(l.value.authorized_amount)),1)]),o("article",Df,[m[12]||(m[12]=o("span",null,"待审核",-1)),o("strong",null,C(q(Ce)(l.value.pending_withdrawals)),1),m[13]||(m[13]=o("em",null,"占用中的提现",-1))])])):Y("",!0),o("nav",$f,[(I(),N(ce,null,Ne(t,g=>be(D,{key:g.id,role:"tab",to:`/admin/${g.id}`,"aria-selected":R.value===g.id,class:he({on:R.value===g.id})},{default:Dt(()=>[ee(C(g.label),1)]),_:2},1032,["to","aria-selected","class"])),64))]),F.value?(I(),N("p",Mf,[ee(" 确认封禁节点 "+C(F.value.display_node_id||F.value.sn)+"?封禁后心跳会被拒绝。 ",1),o("button",{class:"danger",type:"button",onClick:m[0]||(m[0]=g=>ze(F.value,!0))},"确认封禁"),o("button",{class:"texty",type:"button",onClick:m[1]||(m[1]=g=>F.value=null)},"取消")])):Y("",!0),o("section",Vf,[U.value?(I(),N("p",Lf,"正在读取…")):R.value==="orders"?(I(),N("div",Uf,[o("table",null,[m[15]||(m[15]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"金额"),o("th",null,"TxID"),o("th",null,"状态"),o("th",null,"节点"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(u.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Ff,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",jf,C(g.tx_id),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,C(g.display_node_id||g.sn||"—"),1),g.status==="pending"?(I(),N("td",Hf,[o("button",{class:"gold",type:"button",onClick:W=>ge(g)},"审核开通",8,Bf),o("button",{class:"texty",type:"button",onClick:W=>ne(g)},"拒绝",8,Kf)])):(I(),N("td",Gf))]))),128)),u.value.length?Y("",!0):(I(),N("tr",Wf,[...m[14]||(m[14]=[o("td",{colspan:"7",class:"empty"},"还没有授权订单。",-1)])]))])])])):R.value==="nodes"?(I(),N("section",qf,[(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("article",{key:g.sn,class:"plate"},[o("header",null,[o("strong",null,C(g.display_node_id||g.sn),1),o("span",{class:he(["pill",g.is_banned?"rejected":g.status])},[m[16]||(m[16]=o("i",null,null,-1)),ee(C(g.is_banned?"已封禁":q(wt)[g.status]),1)],2)]),o("div",Jf,[g.is_whitelist?(I(),N("span",zf,"白名单")):Y("",!0),o("label",Yf,[o("input",{type:"checkbox",checked:g.is_whitelist,onChange:W=>Je(g)},null,40,Qf),m[17]||(m[17]=o("span",null,null,-1)),m[18]||(m[18]=ee(" 白名单 ",-1))]),g.is_banned?(I(),N("button",{key:1,class:"texty",type:"button",onClick:W=>ze(g,!1)},"解除封禁",8,Xf)):(I(),N("button",{key:2,class:"danger",type:"button",onClick:W=>F.value=g},"封禁",8,Zf))]),g.genesis?(I(),N("p",ed,"[Genesis Special Node - 30 Days Recovery]")):Y("",!0),g.genesis?(I(),N("p",td,"创世特权节点 - 30天回本")):Y("",!0),o("p",nd,C(g.wallet_address),1),o("p",sd,C(g.sn),1),o("p",ld,[o("b",{style:Rn({width:q(Xn)(g.online_rate)})},null,4),ee(C(q(Xn)(g.online_rate))+" 今日在线率",1)]),o("dl",null,[o("div",null,[m[19]||(m[19]=o("dt",null,"显卡",-1)),o("dd",null,C(pt(g)),1)]),o("div",null,[m[20]||(m[20]=o("dt",null,"CPU",-1)),o("dd",null,C(g.cpu_count?`${g.cpu_count} 核`:"—"),1)]),o("div",null,[m[21]||(m[21]=o("dt",null,"内存",-1)),o("dd",null,C(g.memory_gb?`${g.memory_gb} GB`:"—"),1)]),o("div",null,[m[22]||(m[22]=o("dt",null,"权重",-1)),o("dd",null,C(g.hardware_weight),1)]),o("div",null,[m[23]||(m[23]=o("dt",null,"世代",-1)),o("dd",null,C(Be(g)),1)]),o("div",null,[m[24]||(m[24]=o("dt",null,"绑定 IP",-1)),o("dd",null,C(g.client_ip||"—"),1)]),o("div",null,[m[25]||(m[25]=o("dt",null,"今日积分",-1)),o("dd",null,C(q(Ce)(g.today_points)),1)]),o("div",null,[m[26]||(m[26]=o("dt",null,"心跳",-1)),o("dd",null,C(q(Xt)(g.last_heartbeat_at)),1)])])]))),128)),i.value.length?Y("",!0):(I(),N("p",rd,"还没有节点。"))])):R.value==="tasks"?(I(),N("div",id,[o("form",{class:"panel",onSubmit:Gt(Ge,["prevent"])},[m[31]||(m[31]=o("h2",null,"发布任务",-1)),m[32]||(m[32]=o("p",{class:"sub"},"指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。",-1)),o("div",od,[o("label",null,[m[27]||(m[27]=ee("任务类型 ",-1)),we(o("select",{"onUpdate:modelValue":m[2]||(m[2]=g=>E.value.task_type=g),onChange:tt},[(I(),N(ce,null,Ne(b,g=>o("option",{key:g.id,value:g.id},C(g.label),9,ad)),64))],544),[[Hs,E.value.task_type]])]),o("label",null,[m[29]||(m[29]=ee("目标节点 ",-1)),we(o("select",{"onUpdate:modelValue":m[3]||(m[3]=g=>E.value.target_node_sn=g)},[m[28]||(m[28]=o("option",{value:""},"全部在线节点",-1)),(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("option",{key:g.sn,value:g.sn},C(g.display_node_id||g.sn)+" · "+C(g.sn),9,ud))),128))],512),[[Hs,E.value.target_node_sn]])]),o("label",cd,[m[30]||(m[30]=ee("Payload ",-1)),we(o("textarea",{"onUpdate:modelValue":m[4]||(m[4]=g=>E.value.payloadText=g),required:"",spellcheck:"false",onInput:m[5]||(m[5]=g=>P.value=!0)},null,544),[[De,E.value.payloadText]])])]),m[33]||(m[33]=o("button",{class:"gold",type:"submit"},"立即下发任务",-1))],32),o("div",fd,[o("table",null,[m[35]||(m[35]=o("thead",null,[o("tr",null,[o("th",null,"任务"),o("th",null,"类型"),o("th",null,"节点"),o("th",null,"状态"),o("th",null,"完成时间"),o("th",null,"结果")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(y.value,g=>(I(),N("tr",{key:g.task_id},[o("td",{class:"mono",title:g.task_id},C(g.task_id.slice(0,8)),9,dd),o("td",null,C(Ke(g.task_type)),1),o("td",null,C(g.target_node_sn?`${g.display_node_id||"—"} · ${g.target_node_sn}`:"排队中"),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(G[g.status]||g.status),3)]),o("td",null,C(q(Xt)(g.responded_at)),1),o("td",pd,C(g.result_preview||"—"),1)]))),128)),y.value.length?Y("",!0):(I(),N("tr",hd,[...m[34]||(m[34]=[o("td",{colspan:"6",class:"empty"},"还没有任务。",-1)])]))])])])])):R.value==="whitelist"?(I(),N("section",md,[m[41]||(m[41]=o("p",{class:"sub"},"可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。",-1)),o("form",{class:"ip-form",onSubmit:Gt(Se,["prevent"])},[o("label",null,[m[36]||(m[36]=ee("豁免 IP / 网段",-1)),we(o("input",{"onUpdate:modelValue":m[6]||(m[6]=g=>j.value.ip_address=g),required:"",placeholder:"203.0.113.0/24"},null,512),[[De,j.value.ip_address]])]),o("label",null,[m[37]||(m[37]=ee("备注",-1)),we(o("input",{"onUpdate:modelValue":m[7]||(m[7]=g=>j.value.note=g),placeholder:"自营机房出口"},null,512),[[De,j.value.note]])]),m[38]||(m[38]=o("button",{class:"gold",type:"submit"},"添加豁免 IP",-1))],32),o("div",gd,[o("table",null,[m[40]||(m[40]=o("thead",null,[o("tr",null,[o("th",null,"IP"),o("th",null,"备注"),o("th",null,"添加时间"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(c.value,g=>(I(),N("tr",{key:g.id},[o("td",vd,C(g.ip_address),1),o("td",null,C(g.note||"—"),1),o("td",null,C(q(Xt)(g.created_at)),1),o("td",_d,[o("button",{class:"texty",type:"button",onClick:W=>ht(g)},"移除",8,yd)])]))),128)),c.value.length?Y("",!0):(I(),N("tr",bd,[...m[39]||(m[39]=[o("td",{colspan:"4",class:"empty"},"还没有豁免 IP。",-1)])]))])])])])):R.value==="datacenter-apps"?(I(),N("div",wd,[o("table",null,[m[43]||(m[43]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"联系方式"),o("th",null,"节点数"),o("th",null,"网段"),o("th",null,"显卡"),o("th",null,"证明"),o("th",null,"状态"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(h.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Sd,C(g.wallet_address),1),o("td",null,C(g.contact),1),o("td",null,[ee(C(g.node_count)+" ",1),o("span",{class:he(["pill",g.node_count>=5?"pending":"rejected"])},C(g.node_count>=5?"≥ 5 台":"不足 5 台"),3)]),o("td",xd,C(g.subnet_ip),1),o("td",null,C(g.gpu_generation)+" 系",1),o("td",null,C(g.hardware_proof),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),g.status==="pending"?(I(),N("td",Ed,[o("button",{class:"gold",type:"button",onClick:W=>At(g)},"审批通过",8,Cd),o("button",{class:"texty",type:"button",onClick:W=>Rt(g)},"拒绝",8,Td)])):(I(),N("td",Ad))]))),128)),h.value.length?Y("",!0):(I(),N("tr",Rd,[...m[42]||(m[42]=[o("td",{colspan:"9",class:"empty"},"还没有机房申请。",-1)])]))])])])):R.value==="withdrawals"?(I(),N("div",Od,[m[46]||(m[46]=o("p",{class:"sub tab-note"},"折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。",-1)),o("div",Pd,[o("table",null,[m[45]||(m[45]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"USDT 地址"),o("th",null,"积分"),o("th",null,"折算金额"),o("th",null,"状态"),o("th",null,"链上哈希"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(a.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Id,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",null,C(q(Ni)(g.amount))+" USDT",1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,[g.status==="pending"?we((I(),N("input",{key:0,"onUpdate:modelValue":W=>d.value[g.id]=W,placeholder:"交易哈希"},null,8,Nd)),[[De,d.value[g.id]]]):(I(),N("span",kd,C(g.tx_hash||"—"),1))]),g.status==="pending"?(I(),N("td",Dd,[o("button",{class:"gold",type:"button",onClick:W=>X(g)},"确认打款",8,$d),o("button",{class:"texty",type:"button",onClick:W=>Ve(g)},"拒绝",8,Md)])):(I(),N("td",Vd))]))),128)),a.value.length?Y("",!0):(I(),N("tr",Ld,[...m[44]||(m[44]=[o("td",{colspan:"7",class:"empty"},"还没有提现申请。",-1)])]))])])])])):R.value==="users"?(I(),N("div",Ud,[o("table",null,[m[48]||(m[48]=o("thead",null,[o("tr",null,[o("th",null,"钱包"),o("th",null,"用户名"),o("th",null,"角色"),o("th",null,"节点"),o("th",null,"可用积分"),o("th",null,"注册")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(r.value,g=>(I(),N("tr",{key:g.id},[o("td",Fd,C(g.wallet_address),1),o("td",null,C(g.username||"—"),1),o("td",null,C(q(wt)[g.role]),1),o("td",null,C(g.node_count),1),o("td",null,C(q(Ce)(g.available_points)),1),o("td",null,C(q(Xt)(g.created_at)),1)]))),128)),r.value.length?Y("",!0):(I(),N("tr",jd,[...m[47]||(m[47]=[o("td",{colspan:"6",class:"empty"},"还没有用户。",-1)])]))])])])):Y("",!0)])]),_:1})}}},ki=vc({history:zu(),routes:[{path:"/",redirect:()=>hl().role==="admin"?"/admin":"/dashboard"},{path:"/login",component:Dc},{path:"/dashboard",redirect:"/dashboard/nodes"},{path:"/dashboard/:tab",component:Af,meta:{auth:!0}},{path:"/admin",redirect:"/admin/orders"},{path:"/admin/:tab",component:Hd,meta:{auth:!0,admin:!0}}]});ki.beforeEach(e=>{const t=hl();return e.meta.auth&&!t.token?"/login":e.meta.admin&&t.role!=="admin"?"/dashboard":e.path==="/login"&&t.token?t.role==="admin"?"/admin":"/dashboard":!0});ru(fu).use(ki).mount("#app"); diff --git a/web/index.html b/web/index.html index e59e280..d6eb21a 100644 --- a/web/index.html +++ b/web/index.html @@ -7,7 +7,7 @@ - +