控制台改为选项卡,并支持封禁节点与下发计算任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-11 01:33:44 +08:00
parent 80d46dcd5d
commit f0b35e6b34
18 changed files with 933 additions and 300 deletions
+42 -3
View File
@@ -44,10 +44,13 @@ from app.schemas import (
NodeView,
IPWhitelistCreate,
IPWhitelistOut,
BanToggle,
WhitelistToggle,
RegisterIn,
SettleOut,
SummaryOut,
AdminTaskOut,
TaskCreate,
TaskOut,
TaskReportIn,
TokenOut,
@@ -69,7 +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.heartbeat import assert_node_token, record_heartbeat
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
from app.services.portal import (
@@ -81,7 +84,7 @@ from app.services.portal import (
user_rows,
)
from app.services.registry import register_node, register_user
from app.services.tasks import list_pending_tasks, report_task
from app.services.tasks import create_admin_tasks, list_admin_tasks, list_pending_tasks, report_task, result_preview
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
from app.services.yield_engine import compute_node_yield, list_node_yields, network_yields, settle_yield_date
@@ -210,6 +213,14 @@ def admin_toggle_node_whitelist(sn: str, body: WhitelistToggle, _: AdminUser, db
return NodeView.model_validate(snapshot)
@router.post("/admin/nodes/{sn}/ban", response_model=NodeView)
def admin_ban_node(sn: str, body: BanToggle, _: AdminUser, db: Db) -> NodeView:
node = get_node_by_sn(db, sn)
set_node_banned(db, node, body.is_banned)
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
return NodeView.model_validate(snapshot)
@router.get("/admin/ip-whitelist", response_model=list[IPWhitelistOut])
def admin_ip_whitelist(_: AdminUser, db: Db) -> list[IPWhitelist]:
return list_whitelist(db)
@@ -341,7 +352,18 @@ def pending_tasks(sn: str, db: Db, token: str | None = None) -> list[TaskOut]:
@router.post("/node/task/report", response_model=TaskOut)
def report(body: TaskReportIn, db: Db) -> TaskOut:
assert_node_token(get_node_by_sn(db, body.sn), body.token)
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce))
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce, result=body.result))
@router.post("/admin/tasks/create", response_model=list[AdminTaskOut])
def admin_create_tasks(body: TaskCreate, _: AdminUser, db: Db) -> list[AdminTaskOut]:
tasks = create_admin_tasks(db, body.task_type, body.payload, body.target_node_sn)
return [_admin_task_out(task) for task in tasks]
@router.get("/admin/tasks/list", response_model=list[AdminTaskOut])
def admin_list_tasks(_: AdminUser, db: Db) -> list[AdminTaskOut]:
return [_admin_task_out(task) for task in list_admin_tasks(db)]
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
@@ -487,6 +509,23 @@ def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
)
def _admin_task_out(task: ComputeTask) -> AdminTaskOut:
node = task.node
return AdminTaskOut(
task_id=task.id,
task_type=task.task_type,
target_node_sn=node.sn,
display_node_id=node.display_node_id,
status=task.status,
payload=task.payload or {},
result=task.result,
result_preview=result_preview(task.result),
dispatched_at=task.dispatched_at,
deadline_at=task.deadline_at,
responded_at=task.responded_at,
)
def _task_out(task: ComputeTask) -> TaskOut:
return TaskOut(
task_id=task.id,
+3
View File
@@ -90,6 +90,7 @@ class WithdrawalStatus(str, enum.Enum):
class TaskStatus(str, enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
EXPIRED = "expired"
@@ -169,6 +170,7 @@ class ComputeNode(Base):
generation_locked: Mapped[bool] = mapped_column(default=False, comment="世代已按上报显卡锁定")
recoverable_stake_amount: Mapped[Decimal | None] = mapped_column(MONEY, comment="尚未返还的质押金")
is_whitelist: Mapped[bool] = mapped_column(Boolean, default=False, comment="白名单节点,跳过单 IP 限制")
is_banned: Mapped[bool] = mapped_column(Boolean, default=False, comment="封禁后拒绝心跳")
client_ip: Mapped[str | None] = mapped_column(String(64), index=True, comment="最近一次注册或心跳的客户端 IP")
hardware_fingerprint: Mapped[str | None] = mapped_column(
String(64), unique=True, comment="显卡 UUID、主板与网卡合成的硬件指纹"
@@ -257,6 +259,7 @@ class ComputeTask(Base):
deadline_at: Mapped[datetime] = mapped_column(UtcDateTime)
responded_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
success: Mapped[bool | None] = mapped_column()
result: Mapped[dict | None] = mapped_column(JSON, comment="节点回传的任务结果")
node: Mapped[ComputeNode] = relationship(back_populates="tasks")
+8
View File
@@ -20,6 +20,10 @@ def upgrade_schema() -> None:
if "role" not in columns:
statements.append("ALTER TABLE users ADD COLUMN role VARCHAR(16) DEFAULT 'user' NOT NULL")
need_username_index = not _has_username_index(inspector)
if "compute_tasks" in tables:
task_columns = {column["name"] for column in inspector.get_columns("compute_tasks")}
if "result" not in task_columns:
statements.append("ALTER TABLE compute_tasks ADD COLUMN result JSON")
if "compute_nodes" in tables:
node_columns = {column["name"] for column in inspector.get_columns("compute_nodes")}
if "node_token" not in node_columns:
@@ -53,6 +57,10 @@ def upgrade_schema() -> None:
statements.append(
"ALTER TABLE compute_nodes ADD COLUMN is_whitelist BOOLEAN DEFAULT 0 NOT NULL"
)
if "is_banned" not in node_columns:
statements.append(
"ALTER TABLE compute_nodes ADD COLUMN is_banned BOOLEAN DEFAULT 0 NOT NULL"
)
if "client_ip" not in node_columns:
statements.append("ALTER TABLE compute_nodes ADD COLUMN client_ip VARCHAR(64)")
statements.append(
+26
View File
@@ -93,6 +93,7 @@ class NodeOut(APIModel):
generation_locked: bool = False
recoverable_stake_amount: MoneyText | None = None
is_whitelist: bool = False
is_banned: bool = False
client_ip: str | None = None
hardware_fingerprint: str | None = None
@@ -144,6 +145,27 @@ class TaskReportIn(APIModel):
success: bool
nonce: str = Field(min_length=8, max_length=128)
token: str | None = Field(default=None, max_length=128)
result: dict | None = None
class TaskCreate(APIModel):
task_type: str = Field(min_length=1, max_length=32)
target_node_sn: str | None = Field(default=None, max_length=64)
payload: dict = Field(default_factory=dict)
class AdminTaskOut(APIModel):
task_id: str
task_type: str
target_node_sn: str
display_node_id: int
status: TaskStatus
payload: dict
result: dict | None = None
result_preview: str = ""
dispatched_at: datetime
deadline_at: datetime
responded_at: datetime | None = None
class ContributionOut(APIModel):
@@ -293,6 +315,10 @@ class WhitelistToggle(APIModel):
is_whitelist: bool
class BanToggle(APIModel):
is_banned: bool
class IPWhitelistCreate(APIModel):
ip_address: str = Field(min_length=3, max_length=64)
note: str | None = Field(default=None, max_length=128)
+14
View File
@@ -40,6 +40,8 @@ def record_heartbeat(
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
assert_node_token(node, token)
if node.is_banned:
raise AppError("节点已封禁", 403)
if node.license_expires_at <= moment:
raise AppError("节点授权已到期", 403)
bound_ip = (client_ip or "").strip() or None
@@ -65,6 +67,18 @@ def record_heartbeat(
return node
def set_node_banned(session: Session, node: ComputeNode, banned: bool, now: datetime | None = None) -> ComputeNode:
"""封禁立即结束在线区间。解除后要等下一次心跳才会重新上线。"""
moment = now or utcnow()
node.is_banned = banned
node.updated_at = moment
if banned and node.status == NodeStatus.ONLINE:
_close_open_intervals(session, node.id, moment, "banned")
node.status = NodeStatus.OFFLINE
session.flush()
return node
def mark_stale_nodes(session: Session, now: datetime | None = None) -> list[str]:
"""把超过心跳时限或授权已到期的在线节点置为 offline,并关闭在线区间。"""
moment = now or utcnow()
+1
View File
@@ -82,6 +82,7 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
"generation_locked": bool(node.generation_locked),
"recoverable_stake_amount": node.recoverable_stake_amount,
"is_whitelist": bool(node.is_whitelist),
"is_banned": bool(node.is_banned),
"client_ip": node.client_ip,
"hardware_fingerprint": node.hardware_fingerprint,
}
+99 -4
View File
@@ -18,7 +18,7 @@ from urllib.error import URLError
from urllib.request import Request, urlopen
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.config import settings
@@ -28,13 +28,105 @@ from app.services.common import get_node_by_sn
logger = logging.getLogger(__name__)
ADMIN_TASK_TYPES = {
"alpha_mining": "量化因子挖掘",
"llm_inference": "大模型推理",
"lora_training": "QLoRA 微调",
"stress_test": "简易压测",
}
_BUSY = (TaskStatus.PENDING, TaskStatus.PROCESSING)
def create_admin_tasks(
session: Session,
task_type: str,
payload: dict | None,
target_node_sn: str | None = None,
now: datetime | None = None,
) -> list[ComputeTask]:
"""向指定节点或当前全部在线节点各放一条任务。payload 里补上一次性 nonce。"""
if task_type not in ADMIN_TASK_TYPES:
raise AppError("不支持的任务类型", 400)
moment = now or utcnow()
body = dict(payload or {})
if len(json.dumps(body, ensure_ascii=False)) > 8000:
raise AppError("任务参数过长", 400)
targets = _dispatch_targets(session, target_node_sn, moment)
deadline = moment + timedelta(minutes=settings.task_deadline_minutes)
tasks: list[ComputeTask] = []
for node in targets:
task_payload = dict(body)
task_payload["nonce"] = secrets.token_hex(16)
task = ComputeTask(
id=str(uuid.uuid4()),
node_id=node.id,
task_type=task_type,
payload=task_payload,
status=TaskStatus.PENDING,
push_status=PushStatus.QUEUED,
dispatched_at=moment,
deadline_at=deadline,
)
session.add(task)
tasks.append(task)
session.flush()
for node, task in zip(targets, tasks):
_push_task(node, task)
session.flush()
return tasks
def list_admin_tasks(session: Session) -> list[ComputeTask]:
return list(
session.scalars(
select(ComputeTask)
.options(selectinload(ComputeTask.node))
.order_by(ComputeTask.dispatched_at.desc(), ComputeTask.id.desc())
).all()
)
def result_preview(result: dict | None) -> str:
if not result:
return ""
for key in ("formula", "alpha", "output", "summary"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value.strip()[:120]
for key in ("patch_bytes", "patch_size", "adapter_bytes"):
if result.get(key) is not None:
return f"补丁 {result[key]}"
text = json.dumps(result, ensure_ascii=False)
return text if len(text) <= 120 else text[:117] + "..."
def _dispatch_targets(session: Session, target_node_sn: str | None, moment: datetime) -> list[ComputeNode]:
sn = (target_node_sn or "").strip()
if sn:
node = get_node_by_sn(session, sn)
if node.is_banned:
raise AppError("节点已封禁,不能下发任务", 409)
if node.license_expires_at <= moment:
raise AppError("节点授权已到期,不能下发任务", 409)
return [node]
nodes = session.scalars(
select(ComputeNode).where(
ComputeNode.status == NodeStatus.ONLINE,
ComputeNode.license_expires_at > moment,
ComputeNode.is_banned.is_(False),
)
).all()
if not nodes:
raise AppError("当前没有可下发的在线节点", 409)
return list(nodes)
def dispatch_random_tasks(session: Session, now: datetime | None = None) -> list[ComputeTask]:
"""向当前在线、授权仍有效、且没有未完成任务的节点随机下发一轮任务。"""
moment = now or utcnow()
expire_overdue_tasks(session, moment)
pending_node_ids = set(
session.scalars(select(ComputeTask.node_id).where(ComputeTask.status == TaskStatus.PENDING)).all()
session.scalars(select(ComputeTask.node_id).where(ComputeTask.status.in_(_BUSY))).all()
)
candidates = [
node
@@ -90,6 +182,7 @@ def list_pending_tasks(session: Session, sn: str, now: datetime | None = None) -
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
continue
task.status = TaskStatus.PROCESSING
visible.append(task)
session.flush()
return visible
@@ -102,6 +195,7 @@ def report_task(
success: bool,
nonce: str,
now: datetime | None = None,
result: dict | None = None,
) -> ComputeTask:
"""校验任务归属、时限和 nonce。只有校验通过且 success 为真才记为有效响应。"""
moment = now or utcnow()
@@ -111,7 +205,7 @@ def report_task(
raise AppError("任务不存在", 404)
if task.node_id != node.id:
raise AppError("任务不属于该节点", 403)
if task.status != TaskStatus.PENDING:
if task.status not in _BUSY:
raise AppError("任务已结束,不能重复回传", 409)
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
@@ -124,6 +218,7 @@ def report_task(
task.responded_at = moment
task.success = success
task.result = result or None
task.status = TaskStatus.COMPLETED if success else TaskStatus.FAILED
session.flush()
return task
@@ -133,7 +228,7 @@ def expire_overdue_tasks(session: Session, now: datetime | None = None) -> int:
moment = now or utcnow()
tasks = session.scalars(
select(ComputeTask).where(
ComputeTask.status == TaskStatus.PENDING,
ComputeTask.status.in_(_BUSY),
ComputeTask.deadline_at < moment,
)
).all()
+4 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { clearSession, session } from "../api";
defineProps({
@@ -9,6 +9,7 @@ defineProps({
});
const router = useRouter();
const route = useRoute();
const current = session();
const clock = ref("--:--:--");
let timer = 0;
@@ -52,8 +53,8 @@ onUnmounted(() => window.clearInterval(timer));
</span>
</router-link>
<nav class="nav">
<router-link to="/dashboard">我的节点</router-link>
<router-link v-if="current.role === 'admin'" to="/admin">管理后台</router-link>
<router-link to="/dashboard/nodes" :class="{ 'router-link-active': route.path.startsWith('/dashboard') }">我的节点</router-link>
<router-link v-if="current.role === 'admin'" to="/admin/orders" :class="{ 'router-link-active': route.path.startsWith('/admin') }">管理后台</router-link>
</nav>
<div class="rail-foot">
<span class="who">{{ current.username || current.wallet }}</span>
+8
View File
@@ -1,3 +1,11 @@
const POINT_USDT_RATE = 0.95;
export function usdtFromPoints(value) {
const number = Number(value);
if (!Number.isFinite(number)) return "—";
return money(number * POINT_USDT_RATE);
}
export function money(value) {
const [whole, fraction = ""] = String(value ?? "0").split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+4 -2
View File
@@ -9,8 +9,10 @@ export const router = createRouter({
routes: [
{ path: "/", redirect: () => (session().role === "admin" ? "/admin" : "/dashboard") },
{ path: "/login", component: Login },
{ path: "/dashboard", component: Dashboard, meta: { auth: true } },
{ path: "/admin", component: AdminOverview, meta: { auth: true, admin: true } },
{ path: "/dashboard", redirect: "/dashboard/nodes" },
{ path: "/dashboard/:tab", component: Dashboard, meta: { auth: true } },
{ path: "/admin", redirect: "/admin/orders" },
{ path: "/admin/:tab", component: AdminOverview, meta: { auth: true, admin: true } },
],
});
+41 -5
View File
@@ -185,14 +185,15 @@ input:focus { border-color: var(--gold); }
.rate { position: relative; overflow: hidden; margin: 14px 0; padding: 8px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.03); color: var(--muted); font-size: 12px; }
.rate b { position: absolute; inset: 0 auto 0 0; background: rgba(224, 192, 122, 0.18); }
.plate dl { display: grid; gap: 8px; margin: 0; }
.plate dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.plate dt { color: var(--muted); }
.plate dd { margin: 0; }
.plate dl div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 14px; }
.plate dt { flex: none; color: var(--muted); white-space: nowrap; }
.plate dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
.admin-board { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 10px; border: 1px solid var(--line); font-size: 12px; }
.pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.online, .pill.completed { color: var(--online); }
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
.pill.pending { color: var(--gold); }
.pill.pending, .pill.processing { color: var(--gold); }
.genesis-tag {
display: inline-flex;
width: fit-content;
@@ -214,7 +215,42 @@ input:focus { border-color: var(--gold); }
.toggle input:checked + span { border-color: #e0c07a; }
.toggle input:checked + span::after { transform: translateX(18px); background: #e0c07a; }
.toggle input:focus-visible + span { outline: 1px solid #e0c07a; }
.whitelist-panel { margin-top: 8px; }
.whitelist-panel { margin-top: 0; }
.admin-tabs {
display: flex;
gap: 8px;
margin: 28px 0 18px;
padding: 6px;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(8, 9, 13, 0.62);
}
.admin-tabs a {
flex: 0 0 auto;
border: 1px solid transparent;
border-radius: 12px;
padding: 12px 16px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
}
.admin-tabs a.on {
color: var(--gold);
background: rgba(224, 192, 122, 0.1);
border-color: #e0c07a;
box-shadow: 0 0 0 1px rgba(224, 192, 122, 0.28), 0 0 22px rgba(224, 192, 122, 0.24);
}
.tab-panel { min-height: 220px; }
.tab-note { margin-bottom: 12px; }
.confirm-ban { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: #e7b2a6; }
.danger {
border: 1px solid rgba(208, 138, 120, 0.75);
border-radius: 999px;
padding: 8px 14px;
background: rgba(208, 138, 120, 0.08);
color: #e7b2a6;
}
.ip-form { display: grid; grid-template-columns: 1.2fr 1fr auto; gap: 12px; align-items: end; }
.ip-form button { height: 42px; }
@media (max-width: 720px) { .ip-form { grid-template-columns: 1fr; } }
+399 -171
View File
@@ -1,9 +1,22 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Shell from "../components/Shell.vue";
import { api } from "../api";
import { money, pct, statusText, when } from "../format";
import { money, pct, statusText, usdtFromPoints, when } from "../format";
const tabs = [
{ id: "orders", label: "授权订单" },
{ id: "nodes", label: "节点监控" },
{ id: "tasks", label: "任务调度" },
{ id: "whitelist", label: "IP 白名单" },
{ id: "datacenter-apps", label: "机房大客户申请" },
{ id: "withdrawals", label: "提现审核" },
{ id: "users", label: "用户" },
];
const route = useRoute();
const router = useRouter();
const summary = ref(null);
const users = ref([]);
const nodes = ref([]);
@@ -12,28 +25,106 @@ const orders = ref([]);
const hashes = ref({});
const whitelist = ref([]);
const applications = ref([]);
const dispatched = ref([]);
const taskTypes = [
{ id: "alpha_mining", label: "量化因子挖掘", sample: '{\n "operators": ["ts_mean", "rank"],\n "population": 64\n}' },
{ id: "llm_inference", label: "大模型推理", sample: '{\n "model": "qwen2.5-7b",\n "prompt": "summarize the brief",\n "max_tokens": 256\n}' },
{ id: "lora_training", label: "QLoRA 微调", sample: '{\n "base_model": "qwen2.5-7b",\n "rank": 16,\n "steps": 100\n}' },
{ id: "stress_test", label: "简易压测", sample: '{\n "duration_seconds": 60,\n "matrix": 4096\n}' },
];
const taskForm = ref({
task_type: "alpha_mining",
target_node_sn: "",
payloadText: taskTypes[0].sample,
});
const payloadDirty = ref(false);
const taskStatusText = {
pending: "待领取",
processing: "执行中",
completed: "已完成",
failed: "失败",
expired: "已过期",
};
const ipForm = ref({ ip_address: "", note: "" });
const error = ref("");
const notice = ref("");
const tabLoading = ref(false);
const pendingBan = ref(null);
let timer = 0;
let requestSerial = 0;
async function load() {
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders, nextIps, nextApps] = await Promise.all([
api("/admin/summary"),
api("/admin/users"),
api("/admin/nodes"),
api("/admin/withdrawals"),
api("/admin/orders"),
api("/admin/ip-whitelist"),
api("/admin/datacenter-applications"),
]);
summary.value = nextSummary;
users.value = nextUsers;
nodes.value = nextNodes;
withdrawals.value = nextCash;
orders.value = nextOrders;
whitelist.value = nextIps;
applications.value = nextApps;
const activeTab = computed(() => {
const id = String(route.params.tab || "");
return tabs.some((tab) => tab.id === id) ? id : "";
});
function generationText(node) {
if (node.gpu_generation === "40") return "40 系";
if (node.gpu_generation === "30") return "30 系";
return "10 系及以下";
}
function taskTypeText(value) {
const known = taskTypes.find((item) => item.id === value);
if (known) return known.label;
return value === "simulated_compute" ? "模拟计算" : value;
}
function fillTaskSample() {
if (payloadDirty.value) return;
const sample = taskTypes.find((item) => item.id === taskForm.value.task_type);
taskForm.value.payloadText = sample ? sample.sample : "{}";
}
function gpuText(node) {
if (!node.gpu_model) return "未上报";
return node.vram_gb ? `${node.gpu_model} · ${node.vram_gb} GB` : node.gpu_model;
}
async function loadSummary() {
summary.value = await api("/admin/summary");
}
async function loadTab(id, quiet = false) {
const loaders = {
orders: async () => {
orders.value = await api("/admin/orders");
},
nodes: async () => {
nodes.value = await api("/admin/nodes");
},
tasks: async () => {
const [nextTasks, nextNodes] = await Promise.all([api("/admin/tasks/list"), api("/admin/nodes")]);
dispatched.value = nextTasks;
nodes.value = nextNodes;
},
whitelist: async () => {
whitelist.value = await api("/admin/ip-whitelist");
},
"datacenter-apps": async () => {
applications.value = await api("/admin/datacenter-applications");
},
withdrawals: async () => {
withdrawals.value = await api("/admin/withdrawals");
},
users: async () => {
users.value = await api("/admin/users");
},
};
const load = loaders[id];
if (!load) return;
const serial = ++requestSerial;
if (!quiet) tabLoading.value = true;
try {
await load();
} finally {
if (serial === requestSerial) tabLoading.value = false;
}
}
async function refresh() {
await loadSummary();
if (activeTab.value) await loadTab(activeTab.value, true);
}
async function approveOrder(row) {
@@ -42,7 +133,7 @@ async function approveOrder(row) {
try {
const result = await api(`/admin/orders/${row.id}/approve`, { method: "POST" });
notice.value = `订单 #${row.id} 已开通,节点 ${result.display_node_id || result.sn}`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -57,7 +148,7 @@ async function rejectOrder(row) {
body: JSON.stringify({ note: "支付凭证未通过" }),
});
notice.value = `订单 #${row.id} 已拒绝`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -77,7 +168,7 @@ async function confirm(row) {
body: JSON.stringify({ tx_hash: txHash }),
});
notice.value = `提现 #${row.id} 已打款`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -95,7 +186,55 @@ async function toggleWhitelist(node) {
notice.value = next
? `节点 ${node.display_node_id || node.sn} 已加入白名单`
: `节点 ${node.display_node_id || node.sn} 已取消白名单`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
}
async function setBan(node, banned) {
error.value = "";
notice.value = "";
pendingBan.value = null;
try {
await api(`/admin/nodes/${encodeURIComponent(node.sn)}/ban`, {
method: "POST",
body: JSON.stringify({ is_banned: banned }),
});
notice.value = banned
? `节点 ${node.display_node_id || node.sn} 已封禁`
: `节点 ${node.display_node_id || node.sn} 已解除封禁`;
await refresh();
} catch (err) {
error.value = err.message;
}
}
async function createTask() {
error.value = "";
notice.value = "";
let payload;
try {
payload = JSON.parse(taskForm.value.payloadText);
} catch {
error.value = "Payload 不是合法 JSON";
return;
}
if (!payload || Array.isArray(payload) || typeof payload !== "object") {
error.value = "Payload 需要是 JSON 对象";
return;
}
try {
const created = await api("/admin/tasks/create", {
method: "POST",
body: JSON.stringify({
task_type: taskForm.value.task_type,
target_node_sn: taskForm.value.target_node_sn || null,
payload,
}),
});
notice.value = `已下发 ${created.length} 个任务`;
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -114,7 +253,7 @@ async function addIp() {
});
ipForm.value = { ip_address: "", note: "" };
notice.value = "白名单 IP 已添加";
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -126,7 +265,7 @@ async function approveDatacenter(row) {
try {
await api(`/admin/datacenter-applications/${row.id}/approve`, { method: "POST" });
notice.value = `机房申请 #${row.id} 已通过,网段 ${row.subnet_ip} 已加入白名单`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -141,7 +280,7 @@ async function rejectDatacenter(row) {
body: JSON.stringify({ note: "未达到机房门槛" }),
});
notice.value = `机房申请 #${row.id} 已拒绝`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -153,7 +292,7 @@ async function removeIp(row) {
try {
await api(`/admin/ip-whitelist/${row.id}`, { method: "DELETE" });
notice.value = `${row.ip_address} 已移出白名单`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
@@ -168,20 +307,37 @@ async function reject(row) {
body: JSON.stringify({ note: "管理后台拒绝" }),
});
notice.value = `提现 #${row.id} 已拒绝`;
await load();
await refresh();
} catch (err) {
error.value = err.message;
}
}
watch(
activeTab,
async (id) => {
if (!id) {
router.replace("/admin/orders");
return;
}
try {
await loadTab(id);
} catch (err) {
tabLoading.value = false;
error.value = err.message;
}
},
{ immediate: true },
);
onMounted(async () => {
try {
await load();
await loadSummary();
} catch (err) {
error.value = err.message;
}
timer = window.setInterval(() => {
load().catch((err) => {
refresh().catch((err) => {
error.value = err.message;
});
}, 10000);
@@ -217,157 +373,229 @@ onUnmounted(() => window.clearInterval(timer));
</article>
</section>
<h2 class="section-title">授权订单</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>金额</th><th>TxID</th><th>状态</th><th>节点</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in orders" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td class="mono">{{ row.tx_id }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>{{ row.display_node_id || row.sn || "—" }}</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="approveOrder(row)">通过</button>
<button class="texty" type="button" @click="rejectOrder(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!orders.length"><td colspan="7" class="empty">还没有授权订单。</td></tr>
</tbody>
</table>
</div>
<nav class="admin-tabs" role="tablist" aria-label="管理分类">
<router-link
v-for="tab in tabs"
:key="tab.id"
role="tab"
:to="`/admin/${tab.id}`"
:aria-selected="activeTab === tab.id"
:class="{ on: activeTab === tab.id }"
>{{ tab.label }}</router-link>
</nav>
<h2 class="section-title">节点监控</h2>
<section class="board">
<article v-for="node in nodes" :key="node.sn" class="plate">
<header>
<strong>{{ node.display_node_id || node.sn }}</strong>
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
</header>
<div class="whitelist-row">
<span v-if="node.is_whitelist" class="pill pending">白名单</span>
<label class="toggle">
<input type="checkbox" :checked="node.is_whitelist" @change="toggleWhitelist(node)" />
<span></span>
白名单
</label>
</div>
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
<p class="wallet">{{ node.wallet_address }}</p>
<p class="sub">{{ node.sn }}</p>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
<dl>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
<div><dt>绑定 IP</dt><dd>{{ node.client_ip || "—" }}</dd></div>
</dl>
</article>
<p v-if="!nodes.length" class="empty">还没有节点。</p>
</section>
<p v-if="pendingBan" class="banner confirm-ban">
确认封禁节点 {{ pendingBan.display_node_id || pendingBan.sn }}?封禁后心跳会被拒绝。
<button class="danger" type="button" @click="setBan(pendingBan, true)">确认封禁</button>
<button class="texty" type="button" @click="pendingBan = null">取消</button>
</p>
<h2 class="section-title">IP 白名单设置</h2>
<section class="panel whitelist-panel">
<p class="sub">可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。</p>
<form class="ip-form" @submit.prevent="addIp">
<label>豁免 IP / 网段<input v-model="ipForm.ip_address" required placeholder="203.0.113.0/24" /></label>
<label>备注<input v-model="ipForm.note" placeholder="自营机房出口" /></label>
<button class="gold" type="submit">添加</button>
</form>
<div class="table-wrap">
<section class="tab-panel" role="tabpanel">
<p v-if="tabLoading" class="empty">正在读取…</p>
<div v-else-if="activeTab === 'orders'" class="table-wrap">
<table>
<thead>
<tr><th>IP</th><th>备注</th><th>添加时间</th><th></th></tr>
<tr><th>单号</th><th>钱包</th><th>金额</th><th>TxID</th><th>状态</th><th>节点</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in whitelist" :key="row.id">
<td class="mono">{{ row.ip_address }}</td>
<td>{{ row.note || "—" }}</td>
<td>{{ when(row.created_at) }}</td>
<td class="actions"><button class="texty" type="button" @click="removeIp(row)">删除</button></td>
<tr v-for="row in orders" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td class="mono">{{ row.tx_id }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>{{ row.display_node_id || row.sn || "—" }}</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="approveOrder(row)">审核开通</button>
<button class="texty" type="button" @click="rejectOrder(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!whitelist.length"><td colspan="4" class="empty">还没有豁免 IP。</td></tr>
<tr v-if="!orders.length"><td colspan="7" class="empty">还没有授权订单。</td></tr>
</tbody>
</table>
</div>
<section v-else-if="activeTab === 'nodes'" class="board admin-board">
<article v-for="node in nodes" :key="node.sn" class="plate">
<header>
<strong>{{ node.display_node_id || node.sn }}</strong>
<span class="pill" :class="node.is_banned ? 'rejected' : node.status"><i />{{ node.is_banned ? "已封禁" : statusText[node.status] }}</span>
</header>
<div class="whitelist-row">
<span v-if="node.is_whitelist" class="pill pending">白名单</span>
<label class="toggle">
<input type="checkbox" :checked="node.is_whitelist" @change="toggleWhitelist(node)" />
<span></span>
白名单
</label>
<button v-if="node.is_banned" class="texty" type="button" @click="setBan(node, false)">解除封禁</button>
<button v-else class="danger" type="button" @click="pendingBan = node">封禁</button>
</div>
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
<p class="wallet">{{ node.wallet_address }}</p>
<p class="sub">{{ node.sn }}</p>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
<dl>
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
<div><dt>CPU</dt><dd>{{ node.cpu_count ? `${node.cpu_count} 核` : "—" }}</dd></div>
<div><dt>内存</dt><dd>{{ node.memory_gb ? `${node.memory_gb} GB` : "—" }}</dd></div>
<div><dt>权重</dt><dd>{{ node.hardware_weight }}</dd></div>
<div><dt>世代</dt><dd>{{ generationText(node) }}</dd></div>
<div><dt>绑定 IP</dt><dd>{{ node.client_ip || "—" }}</dd></div>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
</dl>
</article>
<p v-if="!nodes.length" class="empty">还没有节点。</p>
</section>
<div v-else-if="activeTab === 'tasks'">
<form class="panel" @submit.prevent="createTask">
<h2>发布任务</h2>
<p class="sub">指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。</p>
<div class="forms">
<label>任务类型
<select v-model="taskForm.task_type" @change="fillTaskSample">
<option v-for="item in taskTypes" :key="item.id" :value="item.id">{{ item.label }}</option>
</select>
</label>
<label>目标节点
<select v-model="taskForm.target_node_sn">
<option value="">全部在线节点</option>
<option v-for="node in nodes" :key="node.sn" :value="node.sn">{{ node.display_node_id || node.sn }} · {{ node.sn }}</option>
</select>
</label>
<label class="span-2">Payload
<textarea v-model="taskForm.payloadText" required spellcheck="false" @input="payloadDirty = true" />
</label>
</div>
<button class="gold" type="submit">立即下发任务</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr><th>任务</th><th>类型</th><th>节点</th><th>状态</th><th>完成时间</th><th>结果</th></tr>
</thead>
<tbody>
<tr v-for="row in dispatched" :key="row.task_id">
<td class="mono" :title="row.task_id">{{ row.task_id.slice(0, 8) }}</td>
<td>{{ taskTypeText(row.task_type) }}</td>
<td>{{ row.display_node_id || "—" }} · {{ row.target_node_sn }}</td>
<td><span class="pill" :class="row.status">{{ taskStatusText[row.status] || row.status }}</span></td>
<td>{{ when(row.responded_at) }}</td>
<td class="mono">{{ row.result_preview || "—" }}</td>
</tr>
<tr v-if="!dispatched.length"><td colspan="6" class="empty">还没有任务。</td></tr>
</tbody>
</table>
</div>
</div>
<section v-else-if="activeTab === 'whitelist'" class="panel whitelist-panel">
<p class="sub">可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。</p>
<form class="ip-form" @submit.prevent="addIp">
<label>豁免 IP / 网段<input v-model="ipForm.ip_address" required placeholder="203.0.113.0/24" /></label>
<label>备注<input v-model="ipForm.note" placeholder="自营机房出口" /></label>
<button class="gold" type="submit">添加豁免 IP</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr><th>IP</th><th>备注</th><th>添加时间</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in whitelist" :key="row.id">
<td class="mono">{{ row.ip_address }}</td>
<td>{{ row.note || "—" }}</td>
<td>{{ when(row.created_at) }}</td>
<td class="actions"><button class="texty" type="button" @click="removeIp(row)">移除</button></td>
</tr>
<tr v-if="!whitelist.length"><td colspan="4" class="empty">还没有豁免 IP。</td></tr>
</tbody>
</table>
</div>
</section>
<div v-else-if="activeTab === 'datacenter-apps'" class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>联系方式</th><th>节点数</th><th>网段</th><th>显卡</th><th>证明</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in applications" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ row.contact }}</td>
<td>
{{ row.node_count }}
<span class="pill" :class="row.node_count >= 5 ? 'pending' : 'rejected'">{{ row.node_count >= 5 ? "≥ 5 台" : "不足 5 台" }}</span>
</td>
<td class="mono">{{ row.subnet_ip }}</td>
<td>{{ row.gpu_generation }} 系</td>
<td>{{ row.hardware_proof }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="approveDatacenter(row)">审批通过</button>
<button class="texty" type="button" @click="rejectDatacenter(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!applications.length"><td colspan="9" class="empty">还没有机房申请。</td></tr>
</tbody>
</table>
</div>
<div v-else-if="activeTab === 'withdrawals'">
<p class="sub tab-note">折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>USDT 地址</th><th>积分</th><th>折算金额</th><th>状态</th><th>链上哈希</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in withdrawals" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td>{{ usdtFromPoints(row.amount) }} USDT</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>
<input v-if="row.status === 'pending'" v-model="hashes[row.id]" placeholder="交易哈希" />
<span v-else class="mono">{{ row.tx_hash || "—" }}</span>
</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="confirm(row)">确认打款</button>
<button class="texty" type="button" @click="reject(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!withdrawals.length"><td colspan="7" class="empty">还没有提现申请。</td></tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="activeTab === 'users'" class="table-wrap">
<table>
<thead>
<tr><th>钱包</th><th>用户名</th><th>角色</th><th>节点</th><th>可用积分</th><th>注册</th></tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="mono">{{ user.wallet_address }}</td>
<td>{{ user.username || "—" }}</td>
<td>{{ statusText[user.role] }}</td>
<td>{{ user.node_count }}</td>
<td>{{ money(user.available_points) }}</td>
<td>{{ when(user.created_at) }}</td>
</tr>
<tr v-if="!users.length"><td colspan="6" class="empty">还没有用户。</td></tr>
</tbody>
</table>
</div>
</section>
<h2 class="section-title">机房大客户申请</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>联系方式</th><th>节点数</th><th>网段</th><th>显卡</th><th>证明</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in applications" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ row.contact }}</td>
<td>{{ row.node_count }}</td>
<td class="mono">{{ row.subnet_ip }}</td>
<td>{{ row.gpu_generation }} 系</td>
<td>{{ row.hardware_proof }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="approveDatacenter(row)">通过</button>
<button class="texty" type="button" @click="rejectDatacenter(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!applications.length"><td colspan="9" class="empty">还没有机房申请。</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">提现审核</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th>链上哈希</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in withdrawals" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>
<input v-if="row.status === 'pending'" v-model="hashes[row.id]" placeholder="交易哈希" />
<span v-else class="mono">{{ row.tx_hash || "—" }}</span>
</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="confirm(row)">打款</button>
<button class="texty" type="button" @click="reject(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!withdrawals.length"><td colspan="6" class="empty">还没有提现申请。</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">用户</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>钱包</th><th>用户名</th><th>角色</th><th>节点</th><th>可用积分</th><th>注册</th></tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="mono">{{ user.wallet_address }}</td>
<td>{{ user.username || "—" }}</td>
<td>{{ statusText[user.role] }}</td>
<td>{{ user.node_count }}</td>
<td>{{ money(user.available_points) }}</td>
<td>{{ when(user.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</Shell>
</template>
+150 -83
View File
@@ -1,8 +1,23 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Shell from "../components/Shell.vue";
import { api, download } from "../api";
import { money, pct, statusText, when } from "../format";
import { money, pct, statusText, usdtFromPoints, when } from "../format";
const tabs = [
{ id: "nodes", label: "我的节点" },
{ id: "orders", label: "授权订单" },
{ id: "withdrawals", label: "申请提现" },
{ id: "datacenter", label: "机房申请" },
];
const route = useRoute();
const router = useRouter();
const activeTab = computed(() => {
const id = String(route.params.tab || "");
return tabs.some((tab) => tab.id === id) ? id : "";
});
const board = ref(null);
const error = ref("");
@@ -96,6 +111,14 @@ function requestCash() {
}, "提现申请已提交");
}
watch(
activeTab,
(id) => {
if (!id) router.replace("/dashboard/nodes");
},
{ immediate: true },
);
onMounted(async () => {
try {
await load();
@@ -135,88 +158,132 @@ onUnmounted(() => window.clearInterval(timer));
<p class="fine">* 算力积分兑换汇率:1 积分 = 0.8 ~ 1.0 USDT(根据全网算力动态浮动,当前基准约 0.95 USDT)</p>
</section>
<section class="board">
<article v-for="order in board?.orders || []" :key="order.id" class="plate" :class="{ locked: order.status !== 'approved' }">
<header>
<strong>订单 #{{ order.id }}</strong>
<span class="pill" :class="order.status"><i />{{ statusText[order.status] }}</span>
</header>
<p class="wallet">{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}</p>
<p v-if="order.status === 'pending'" class="sub">审核通过前不能复制 Token,也不能下载客户端。</p>
<p v-else-if="order.display_node_id" class="sub">节点 {{ order.display_node_id }}</p>
<div v-if="order.status !== 'approved'" class="actions">
<button class="gold" type="button" disabled>复制 Token</button>
<button class="texty" type="button" disabled>下载客户端</button>
</div>
</article>
<article v-for="node in board?.nodes || []" :key="node.sn" class="plate">
<header>
<strong>{{ node.display_node_id || node.sn }}</strong>
<span class="pill" :class="node.activated ? node.status : 'pending'"><i />{{ node.activated ? statusText[node.status] : "待审核" }}</span>
</header>
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
<dl>
<div><dt>购买金额</dt><dd>{{ money(node.purchase_amount) }}</dd></div>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
<div><dt>硬件权重</dt><dd>{{ node.hardware_weight }}</dd></div>
<div><dt>显卡世代</dt><dd>{{ generationText(node) }}</dd></div>
<div><dt>回本周期</dt><dd>{{ node.recovery_days }} 天</dd></div>
<div><dt>待返还质押</dt><dd>{{ money(node.recoverable_stake_amount || 0) }}</dd></div>
</dl>
<label>挂机 Token<input class="token-box" :value="node.activated ? node.node_token : '审核通过后发放'" readonly /></label>
<div class="actions">
<button class="gold" type="button" :disabled="!node.activated" @click="copyToken(node.node_token)">复制 Token</button>
<button class="texty" type="button" :disabled="!node.activated" @click="downloadClient(node)">下载客户端</button>
</div>
</article>
<p v-if="board && !board.nodes.length && !board.orders.length" class="empty">还没有节点。下面可以购买授权。</p>
</section>
<nav class="admin-tabs" role="tablist" aria-label="我的节点">
<router-link
v-for="tab in tabs"
:key="tab.id"
role="tab"
:to="`/dashboard/${tab.id}`"
:aria-selected="activeTab === tab.id"
:class="{ on: activeTab === tab.id }"
>{{ tab.label }}</router-link>
</nav>
<section class="forms">
<form class="panel" @submit.prevent="placeOrder">
<h2>购买节点授权</h2>
<p class="sub">{{ board ? money(board.license_price) : "1,500" }} {{ board?.license_asset || "USDT" }}。提交链上 TxID 后进入待审核,通过后开通 12 个月并签发 Token。</p>
<label>TxID<input v-model="txId" required minlength="6" placeholder="链上交易哈希" /></label>
<button class="gold" type="submit">提交支付凭证</button>
</form>
<form class="panel" @submit.prevent="requestCash">
<h2>申请提现</h2>
<p class="sub">提交申请后,相应资产将进入安全清算锁定状态,审核通过后将统一打款至您的绑定地址。</p>
<label>金额<input v-model="cashForm.amount" required placeholder="1.25" /></label>
<label>资产<input v-model="cashForm.asset_symbol" /></label>
<button class="gold" type="submit">提交申请</button>
<ul class="mini">
<li v-for="row in board?.withdrawals || []" :key="row.id">
<span>#{{ row.id }} · {{ money(row.amount) }} {{ row.asset_symbol }}</span>
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
</li>
</ul>
</form>
<form class="panel span-2" @submit.prevent="applyDatacenter">
<h2>机房白名单申请</h2>
<p class="sub">同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。</p>
<label>联系方式<input v-model="datacenterForm.contact" required minlength="4" placeholder="Telegram / 邮箱 / 手机" /></label>
<label>预估节点数<input v-model="datacenterForm.node_count" required type="number" min="5" placeholder="5" /></label>
<label>出口网段<input v-model="datacenterForm.subnet_ip" required placeholder="203.0.113.0/24" /></label>
<label>显卡世代
<select v-model="datacenterForm.gpu_generation">
<option value="40">40 系及以上</option>
<option value="30">30 系</option>
</select>
</label>
<label>硬件配置证明<textarea v-model="datacenterForm.hardware_proof" required minlength="4" placeholder="例如 8 台 RTX 4090,附采购或机房照片说明" /></label>
<button class="gold" type="submit">提交机房申请</button>
<ul class="mini">
<li v-for="row in board?.datacenter_applications || []" :key="row.id">
<span>#{{ row.id }} · {{ row.subnet_ip }} · {{ row.node_count }} 台</span>
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
</li>
</ul>
</form>
<section class="tab-panel" role="tabpanel">
<p v-if="!board" class="empty">正在读取…</p>
<section v-else-if="activeTab === 'nodes'" class="board admin-board">
<article v-for="node in board.nodes" :key="node.sn" class="plate">
<header>
<strong>{{ node.display_node_id || node.sn }}</strong>
<span class="pill" :class="node.is_banned ? 'rejected' : (node.activated ? node.status : 'pending')"><i />{{ node.is_banned ? "已封禁" : (node.activated ? statusText[node.status] : "待审核") }}</span>
</header>
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
<dl>
<div><dt>购买金额</dt><dd>{{ money(node.purchase_amount) }}</dd></div>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
<div><dt>硬件权重</dt><dd>{{ node.hardware_weight }}</dd></div>
<div><dt>显卡世代</dt><dd>{{ generationText(node) }}</dd></div>
<div><dt>回本周期</dt><dd>{{ node.recovery_days }} 天</dd></div>
<div><dt>待返还质押</dt><dd>{{ money(node.recoverable_stake_amount || 0) }}</dd></div>
</dl>
<label>挂机 Token<input class="token-box" :value="node.activated ? node.node_token : '审核通过后发放'" readonly /></label>
<div class="actions">
<button class="gold" type="button" :disabled="!node.activated" @click="copyToken(node.node_token)">复制 Token</button>
<button class="texty" type="button" :disabled="!node.activated" @click="downloadClient(node)">下载客户端</button>
</div>
</article>
<p v-if="!board.nodes.length" class="empty">还没有节点。到「授权订单」提交支付凭证。</p>
</section>
<div v-else-if="activeTab === 'orders'">
<form class="panel" @submit.prevent="placeOrder">
<h2>购买节点授权</h2>
<p class="sub">{{ money(board.license_price) }} {{ board.license_asset || "USDT" }}。提交链上 TxID 后进入待审核,通过后开通 12 个月并签发 Token。</p>
<label>TxID<input v-model="txId" required minlength="6" placeholder="链上交易哈希" /></label>
<button class="gold" type="submit">提交支付凭证</button>
</form>
<section class="board">
<article v-for="order in board.orders" :key="order.id" class="plate" :class="{ locked: order.status !== 'approved' }">
<header>
<strong>订单 #{{ order.id }}</strong>
<span class="pill" :class="order.status"><i />{{ statusText[order.status] }}</span>
</header>
<p class="wallet">{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}</p>
<p v-if="order.status === 'pending'" class="sub">审核通过前不能复制 Token,也不能下载客户端。</p>
<p v-else-if="order.display_node_id" class="sub">节点 {{ order.display_node_id }}</p>
</article>
<p v-if="!board.orders.length" class="empty">还没有授权订单。</p>
</section>
</div>
<div v-else-if="activeTab === 'withdrawals'">
<form class="panel" @submit.prevent="requestCash">
<h2>申请提现</h2>
<p class="sub">提交申请后,相应积分进入锁定。审核通过后打到注册时填写的 USDT 地址。折算按当前基准 1 积分 ≈ 0.95 USDT。</p>
<label>积分数量<input v-model="cashForm.amount" required placeholder="1.25" /></label>
<label>资产<input v-model="cashForm.asset_symbol" /></label>
<button class="gold" type="submit">提交申请</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>积分</th><th>折算金额</th><th>状态</th><th>时间</th></tr>
</thead>
<tbody>
<tr v-for="row in board.withdrawals" :key="row.id">
<td>#{{ row.id }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td>{{ usdtFromPoints(row.amount) }} USDT</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>{{ when(row.created_at) }}</td>
</tr>
<tr v-if="!board.withdrawals.length"><td colspan="5" class="empty">还没有提现申请。</td></tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="activeTab === 'datacenter'">
<form class="panel" @submit.prevent="applyDatacenter">
<h2>机房白名单申请</h2>
<p class="sub">同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。</p>
<div class="forms">
<label>联系方式<input v-model="datacenterForm.contact" required minlength="4" placeholder="Telegram / 邮箱 / 手机" /></label>
<label>预估节点数<input v-model="datacenterForm.node_count" required type="number" min="5" placeholder="5" /></label>
<label>出口网段<input v-model="datacenterForm.subnet_ip" required placeholder="203.0.113.0/24" /></label>
<label>显卡世代
<select v-model="datacenterForm.gpu_generation">
<option value="40">40 系及以上</option>
<option value="30">30 系</option>
</select>
</label>
<label class="span-2">硬件配置证明<textarea v-model="datacenterForm.hardware_proof" required minlength="4" placeholder="例如 8 台 RTX 4090,附采购或机房照片说明" /></label>
</div>
<button class="gold" type="submit">提交机房申请</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>网段</th><th>节点数</th><th>显卡</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="row in board.datacenter_applications" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.subnet_ip }}</td>
<td>{{ row.node_count }}</td>
<td>{{ row.gpu_generation }} 系</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
</tr>
<tr v-if="!board.datacenter_applications.length"><td colspan="5" class="empty">还没有机房申请。</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</Shell>
</template>
+91
View File
@@ -95,6 +95,97 @@ class CoreFlowTest(unittest.TestCase):
denied = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
self.assertEqual(denied.status_code, 403, denied.text)
def test_banned_node_rejects_heartbeat(self) -> None:
self._register("0xban", "NODE-BAN", "100")
online = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
self.assertEqual(online.status_code, 200, online.text)
banned = self.client.post(
"/api/v1/admin/nodes/NODE-BAN/ban",
headers=self._admin_headers(),
json={"is_banned": True},
)
self.assertEqual(banned.status_code, 200, banned.text)
self.assertTrue(banned.json()["is_banned"])
self.assertEqual(banned.json()["status"], "offline")
denied = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
self.assertEqual(denied.status_code, 403, denied.text)
self.assertEqual(denied.json()["detail"], "节点已封禁")
released = self.client.post(
"/api/v1/admin/nodes/NODE-BAN/ban",
headers=self._admin_headers(),
json={"is_banned": False},
)
self.assertEqual(released.status_code, 200, released.text)
self.assertFalse(released.json()["is_banned"])
again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
self.assertEqual(again.status_code, 200, again.text)
def test_admin_can_dispatch_and_collect_task_results(self) -> None:
self._register("0xtask1", "NODE-TASK-1", "100")
self._register("0xtask2", "NODE-TASK-2", "100")
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-1"})
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-2"})
headers = self._admin_headers()
missing = self.client.post(
"/api/v1/admin/tasks/create",
headers=headers,
json={"task_type": "not_a_task", "payload": {}},
)
self.assertEqual(missing.status_code, 400, missing.text)
broadcast = self.client.post(
"/api/v1/admin/tasks/create",
headers=headers,
json={"task_type": "alpha_mining", "payload": {"operators": ["rank"], "population": 32}},
)
self.assertEqual(broadcast.status_code, 200, broadcast.text)
self.assertEqual(len(broadcast.json()), 2)
self.assertEqual({row["status"] for row in broadcast.json()}, {"pending"})
targeted = self.client.post(
"/api/v1/admin/tasks/create",
headers=headers,
json={
"task_type": "lora_training",
"target_node_sn": "NODE-TASK-1",
"payload": {"base_model": "qwen2.5-7b", "rank": 8},
},
)
self.assertEqual(targeted.status_code, 200, targeted.text)
task = targeted.json()[0]
self.assertEqual(task["target_node_sn"], "NODE-TASK-1")
claimed = self.client.get("/api/v1/node/NODE-TASK-1/tasks")
self.assertEqual(claimed.status_code, 200, claimed.text)
handed = next(row for row in claimed.json() if row["task_id"] == task["task_id"])
self.assertEqual(handed["status"], "processing")
reported = self.client.post(
"/api/v1/node/task/report",
json={
"sn": "NODE-TASK-1",
"task_id": task["task_id"],
"success": True,
"nonce": handed["payload"]["nonce"],
"result": {"formula": "rank(ts_mean(close, 12))", "patch_bytes": 4096},
},
)
self.assertEqual(reported.status_code, 200, reported.text)
listed = self.client.get("/api/v1/admin/tasks/list", headers=headers)
self.assertEqual(listed.status_code, 200, listed.text)
done = next(row for row in listed.json() if row["task_id"] == task["task_id"])
self.assertEqual(done["status"], "completed")
self.assertEqual(done["result_preview"], "rank(ts_mean(close, 12))")
self._register("0xtask3", "NODE-TASK-3", "100")
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-3"})
self.client.post(
"/api/v1/admin/nodes/NODE-TASK-3/ban",
headers=headers,
json={"is_banned": True},
)
blocked = self.client.post(
"/api/v1/admin/tasks/create",
headers=headers,
json={"task_type": "stress_test", "target_node_sn": "NODE-TASK-3", "payload": {"matrix": 128}},
)
self.assertEqual(blocked.status_code, 409, blocked.text)
def test_task_report_requires_nonce(self) -> None:
self._register("0xdef", "NODE-T", "50")
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-T"})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,560;9..144,650&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-G_zbj9YV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Xg5W9LDE.css">
<script type="module" crossorigin src="/assets/index-Lxi5Kb1N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BA5SUri0.css">
</head>
<body>
<div id="app"></div>