提供浏览器控制台,并补齐列表接口供页面读取。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 22:20:45 +08:00
parent 2a847263f2
commit 78601aae5e
8 changed files with 1020 additions and 4 deletions
+70 -2
View File
@@ -1,14 +1,17 @@
"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。""" """对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow from app.clock import utcnow
from app.database import get_db from app.database import get_db
from app.models import ComputeTask from app.models import ComputeNode, ComputeTask, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
from app.money import money, to_decimal
from app.schemas import ( from app.schemas import (
BalanceOut, BalanceOut,
ContributionOut, ContributionOut,
@@ -16,7 +19,9 @@ from app.schemas import (
HeartbeatOut, HeartbeatOut,
NodeCreate, NodeCreate,
NodeOut, NodeOut,
NodeView,
SettleOut, SettleOut,
SummaryOut,
TaskOut, TaskOut,
TaskReportIn, TaskReportIn,
UserCreate, UserCreate,
@@ -25,6 +30,7 @@ from app.schemas import (
WithdrawalCreate, WithdrawalCreate,
WithdrawalOut, WithdrawalOut,
WithdrawalReject, WithdrawalReject,
WithdrawalView,
YieldOut, YieldOut,
) )
from app.services.common import get_node_by_sn, get_user_by_wallet from app.services.common import get_node_by_sn, get_user_by_wallet
@@ -38,11 +44,51 @@ router = APIRouter(prefix="/api/v1")
Db = Annotated[Session, Depends(get_db)] Db = Annotated[Session, Depends(get_db)]
@router.get("/summary", response_model=SummaryOut)
def summary(db: Db) -> SummaryOut:
users = db.scalar(select(func.count()).select_from(User)) or 0
nodes = db.scalar(select(func.count()).select_from(ComputeNode)) or 0
online = db.scalar(
select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE)
) or 0
authorized = _sum(db.scalars(select(ComputeNode.purchase_amount)).all())
settled = _sum(
db.scalars(select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)).all()
)
pending = _sum(
db.scalars(
select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING)
).all()
)
return SummaryOut(
users=users,
nodes=nodes,
online=online,
offline=nodes - online,
authorized_amount=authorized,
settled_points=settled,
pending_withdrawals=pending,
)
@router.get("/users", response_model=list[UserOut])
def list_users(db: Db) -> list[User]:
return list(db.scalars(select(User).order_by(User.id.desc())).all())
@router.post("/users", response_model=UserOut) @router.post("/users", response_model=UserOut)
def create_user(body: UserCreate, db: Db) -> UserOut: def create_user(body: UserCreate, db: Db) -> UserOut:
return register_user(db, body.wallet_address) return register_user(db, body.wallet_address)
@router.get("/nodes", response_model=list[NodeView])
def list_nodes(db: Db) -> list[NodeView]:
rows = db.scalars(
select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc())
).all()
return [_node_view(row) for row in rows]
@router.post("/nodes", response_model=NodeOut) @router.post("/nodes", response_model=NodeOut)
def create_node(body: NodeCreate, db: Db) -> NodeOut: def create_node(body: NodeCreate, db: Db) -> NodeOut:
return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url) return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
@@ -99,6 +145,20 @@ def yields(sn: str, db: Db) -> list[YieldOut]:
return list_node_yields(db, sn) return list_node_yields(db, sn)
@router.get("/withdrawals", response_model=list[WithdrawalView])
def list_withdrawals(db: Db) -> list[WithdrawalView]:
rows = db.scalars(
select(WithdrawalLog).options(selectinload(WithdrawalLog.user)).order_by(WithdrawalLog.id.desc())
).all()
return [
WithdrawalView(
**WithdrawalOut.model_validate(row).model_dump(),
wallet_address=row.user.wallet_address,
)
for row in rows
]
@router.get("/users/{wallet_address}/balance", response_model=BalanceOut) @router.get("/users/{wallet_address}/balance", response_model=BalanceOut)
def balance(wallet_address: str, db: Db) -> BalanceOut: def balance(wallet_address: str, db: Db) -> BalanceOut:
user = get_user_by_wallet(db, wallet_address) user = get_user_by_wallet(db, wallet_address)
@@ -121,6 +181,14 @@ def reject(withdrawal_id: int, body: WithdrawalReject, db: Db) -> WithdrawalOut:
return reject_withdrawal(db, withdrawal_id, body.note) return reject_withdrawal(db, withdrawal_id, body.note)
def _node_view(node: ComputeNode) -> NodeView:
return NodeView(**NodeOut.model_validate(node).model_dump(), wallet_address=node.user.wallet_address)
def _sum(values: list[object]) -> Decimal:
return money(sum((to_decimal(item) for item in values), Decimal("0")))
def _task_out(task: ComputeTask) -> TaskOut: def _task_out(task: ComputeTask) -> TaskOut:
return TaskOut( return TaskOut(
task_id=task.id, task_id=task.id,
+12 -2
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import app.models # noqa: F401 注册全部表 import app.models # noqa: F401 注册全部表
from app.api import router from app.api import router
@@ -27,8 +29,16 @@ async def lifespan(_: FastAPI):
stop_scheduler() stop_scheduler()
app = FastAPI(title="DePIN 算力节点与任务调度", version="1.0.0", lifespan=lifespan) WEB_DIR = Path(__file__).resolve().parent.parent / "web"
app = FastAPI(title="HyperNode-AI", version="1.0.0", lifespan=lifespan)
app.include_router(router) app.include_router(router)
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
@app.get("/")
def console() -> FileResponse:
return FileResponse(WEB_DIR / "index.html")
@app.exception_handler(AppError) @app.exception_handler(AppError)
+18
View File
@@ -49,6 +49,10 @@ class NodeOut(APIModel):
callback_url: str | None callback_url: str | None
class NodeView(NodeOut):
wallet_address: str
class HeartbeatIn(APIModel): class HeartbeatIn(APIModel):
sn: SN sn: SN
@@ -151,6 +155,20 @@ class WithdrawalOut(APIModel):
updated_at: datetime updated_at: datetime
class WithdrawalView(WithdrawalOut):
wallet_address: str
class BalanceOut(APIModel): class BalanceOut(APIModel):
wallet_address: str wallet_address: str
available_points: MoneyText available_points: MoneyText
class SummaryOut(APIModel):
users: int
nodes: int
online: int
offline: int
authorized_amount: MoneyText
settled_points: MoneyText
pending_withdrawals: MoneyText
+498
View File
@@ -0,0 +1,498 @@
const viewRoot = document.querySelector("#view");
const titleEl = document.querySelector("#title");
const toastEl = document.querySelector("#toast");
const state = {
view: "overview",
summary: null,
nodes: [],
users: [],
withdrawals: [],
yields: [],
selected: "",
};
const copy = {
overview: "总览",
nodes: "节点",
ledger: "流水",
cash: "提现",
};
document.querySelectorAll("#nav button").forEach((button) => {
button.addEventListener("click", () => go(button.dataset.view));
});
document.querySelector("#refresh").addEventListener("click", () => load().catch(showError));
window.addEventListener("hashchange", syncFromHash);
tick();
setInterval(tick, 1000);
syncFromHash();
load().catch(showError);
probeHealth();
function tick() {
document.querySelector("#clock").textContent = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
}
function go(view) {
location.hash = view;
}
async function syncFromHash() {
const next = (location.hash || "#overview").slice(1);
state.view = copy[next] ? next : "overview";
document.querySelectorAll("#nav button").forEach((button) => {
button.classList.toggle("active", button.dataset.view === state.view);
});
titleEl.textContent = copy[state.view];
if (state.view === "ledger" && state.selected) {
try {
state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`);
} catch (error) {
showError(error);
}
}
render();
}
async function load() {
const [summary, nodes, users, withdrawals] = await Promise.all([
api("/summary"),
api("/nodes"),
api("/users"),
api("/withdrawals"),
]);
state.summary = summary;
state.nodes = nodes;
state.users = users;
state.withdrawals = withdrawals;
if (!state.selected && nodes[0]) state.selected = nodes[0].sn;
if (state.view === "ledger" && state.selected) {
state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`);
}
render();
}
async function probeHealth() {
const dot = document.querySelector("#health-dot");
const text = document.querySelector("#health-text");
try {
const res = await fetch("/health");
if (!res.ok) throw new Error("offline");
dot.classList.add("on");
text.textContent = "服务在线";
} catch {
text.textContent = "服务未响应";
}
}
function render() {
const painters = { overview: paintOverview, nodes: paintNodes, ledger: paintLedger, cash: paintCash };
viewRoot.innerHTML = painters[state.view]();
bind();
}
function paintOverview() {
const s = state.summary || {};
return `
<section class="metrics">
${metric("授权金额", money(s.authorized_amount), `${s.nodes || 0} 台节点`)}
${metric("在线", `${s.online || 0}`, `离线 ${s.offline || 0}`)}
${metric("已入账积分", money(s.settled_points), `${s.users || 0} 个钱包`)}
${metric("待处理提现", money(s.pending_withdrawals), "占用可用余额")}
</section>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>节点</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.slice(0, 8).map(nodeRow).join("") : `<tr><td colspan="5" class="empty">还没有节点。下面可以登记第一台。</td></tr>`}
</tbody>
</table>
</div>
<section class="forms">
${userForm()}
${nodeForm()}
</section>
`;
}
function paintNodes() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>序列号</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.map(nodeRow).join("") : `<tr><td colspan="5" class="empty">节点列表是空的。</td></tr>`}
</tbody>
</table>
</div>
${nodeForm()}
</section>
`;
}
function paintLedger() {
const options = state.nodes.map((node) => `<option value="${esc(node.sn)}" ${node.sn === state.selected ? "selected" : ""}>${esc(node.sn)}</option>`).join("");
return `
<div class="toolbar">
<label class="grow">节点
<select id="ledger-node">${options || `<option value="">暂无节点</option>`}</select>
</label>
<label>清算日期
<input id="settle-date" type="date" />
</label>
<button class="gold" id="settle" type="button">清算</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>日期</th><th>积分</th><th>算力产出</th><th>在线率</th><th>响应率</th><th>表现</th><th>状态</th></tr></thead>
<tbody>
${state.yields.length ? state.yields.map(yieldRow).join("") : `<tr><td colspan="7" class="empty">这一天还没有入账记录。</td></tr>`}
</tbody>
</table>
</div>
`;
}
function paintCash() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th></th></tr></thead>
<tbody>
${state.withdrawals.length ? state.withdrawals.map(cashRow).join("") : `<tr><td colspan="5" class="empty">还没有提现申请。</td></tr>`}
</tbody>
</table>
</div>
<div class="stack">
<form class="panel" id="balance-form">
<h2>可用积分</h2>
<p class="sub">已入账积分减去待处理和已完成的提现。</p>
<label>钱包
<input name="wallet_address" list="wallets" required placeholder="0x..." />
</label>
<div class="row-actions" style="margin-top:14px">
<button class="gold" type="submit">查询</button>
</div>
<p class="sub" id="balance-result"></p>
${walletList()}
</form>
<form class="panel" id="withdraw-form">
<h2>申请提现</h2>
<p class="sub">提交后金额会被占用,确认时再写入链上哈希。</p>
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>金额<input name="amount" required placeholder="1.25" /></label>
<label>资产<input name="asset_symbol" value="POINT" /></label>
<button class="gold" type="submit">提交申请</button>
</div>
</form>
</div>
</section>
`;
}
function metric(label, value, note) {
return `<article class="metric"><span>${esc(label)}</span><strong>${esc(value ?? "0")}</strong><em>${esc(note || "")}</em></article>`;
}
function userForm() {
return `
<form class="panel" id="user-form">
<h2>登记钱包</h2>
<p class="sub">一个钱包可以绑定多台节点。</p>
<label>钱包地址<input name="wallet_address" required minlength="4" placeholder="0xabc123" /></label>
<div class="row-actions" style="margin-top:14px"><button class="gold" type="submit">创建用户</button></div>
</form>
`;
}
function nodeForm() {
return `
<form class="panel" id="node-form">
<h2>登记节点</h2>
<p class="sub">授权周期 12 个月,购买金额按天释放。</p>
${walletList()}
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>序列号<input name="sn" required minlength="4" placeholder="NODE-001" /></label>
<label>购买金额<input name="purchase_amount" required placeholder="365" /></label>
<label>回调地址<input name="callback_url" placeholder="https:// 可选" /></label>
<button class="gold" type="submit">创建节点</button>
</div>
</form>
`;
}
function walletList() {
const options = state.users.map((user) => `<option value="${esc(user.wallet_address)}"></option>`).join("");
return `<datalist id="wallets">${options}</datalist>`;
}
function nodeRow(node) {
return `<tr class="clickable" role="button" tabindex="0" data-sn="${esc(node.sn)}">
<td class="mono">${esc(node.sn)}</td>
<td class="mono">${esc(short(node.wallet_address))}</td>
<td>${esc(money(node.purchase_amount))}</td>
<td>${pill(node.status)}</td>
<td>${esc(day(node.license_expires_at))}</td>
</tr>`;
}
function yieldRow(row) {
return `<tr>
<td class="mono">${esc(row.yield_date)}</td>
<td>${esc(money(row.points_delta))}</td>
<td>${esc(money(row.compute_output))}</td>
<td>${esc(pct(row.online_rate))}</td>
<td>${esc(pct(row.task_response_rate))}</td>
<td>${esc(pct(row.performance))}</td>
<td>${pill(row.status)}</td>
</tr>`;
}
function cashRow(row) {
const actions = row.status === "pending"
? `<div class="row-actions">
<input data-hash="${row.id}" placeholder="交易哈希" />
<button class="texty" data-confirm="${row.id}" type="button">确认</button>
<button class="texty" data-reject="${row.id}" type="button">拒绝</button>
</div>`
: `<span class="mono">${esc(row.tx_hash || "—")}</span>`;
return `<tr>
<td class="mono">#${row.id}</td>
<td class="mono">${esc(short(row.wallet_address))}</td>
<td>${esc(money(row.amount))} ${esc(row.asset_symbol)}</td>
<td>${pill(row.status)}</td>
<td>${actions}</td>
</tr>`;
}
function pill(status) {
const label = {
online: "在线",
offline: "离线",
pending: "待处理",
completed: "已完成",
rejected: "已拒绝",
failed: "失败",
settled: "已入账",
void: "作废",
}[status] || status;
return `<span class="pill ${esc(status)}"><i></i>${esc(label)}</span>`;
}
function bind() {
const userFormEl = document.querySelector("#user-form");
const nodeFormEl = document.querySelector("#node-form");
const balanceForm = document.querySelector("#balance-form");
const withdrawForm = document.querySelector("#withdraw-form");
if (userFormEl) userFormEl.addEventListener("submit", onCreateUser);
if (nodeFormEl) nodeFormEl.addEventListener("submit", onCreateNode);
if (balanceForm) balanceForm.addEventListener("submit", onBalance);
if (withdrawForm) withdrawForm.addEventListener("submit", onWithdraw);
document.querySelectorAll("tr.clickable").forEach((row) => row.addEventListener("click", () => openNode(row.dataset.sn)));
const ledgerNode = document.querySelector("#ledger-node");
if (ledgerNode) {
ledgerNode.addEventListener("change", async () => {
state.selected = ledgerNode.value;
state.yields = state.selected ? await api(`/nodes/${encodeURIComponent(state.selected)}/yields`) : [];
render();
});
}
const settle = document.querySelector("#settle");
if (settle) settle.addEventListener("click", onSettle);
document.querySelectorAll("[data-confirm]").forEach((button) => button.addEventListener("click", () => onConfirm(button.dataset.confirm)));
document.querySelectorAll("[data-reject]").forEach((button) => button.addEventListener("click", () => onReject(button.dataset.reject)));
}
async function onCreateUser(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
await run(() => api("/users", { method: "POST", body: JSON.stringify(body) }), "钱包已登记");
}
async function onCreateNode(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
if (!body.callback_url) delete body.callback_url;
await run(() => api("/nodes", { method: "POST", body: JSON.stringify(body) }), "节点已登记");
}
async function onBalance(event) {
event.preventDefault();
const wallet = new FormData(event.target).get("wallet_address");
const data = await api(`/users/${encodeURIComponent(wallet)}/balance`);
document.querySelector("#balance-result").textContent = `${data.wallet_address} 可用 ${money(data.available_points)}`;
}
async function onWithdraw(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
await run(() => api("/withdrawals", { method: "POST", body: JSON.stringify(body) }), "提现申请已提交");
}
async function onSettle() {
try {
const date = document.querySelector("#settle-date").value;
const query = date ? `?yield_date=${date}` : "";
const result = await api(`/yield/settle${query}`, { method: "POST" });
showToast(`已清算 ${result.yield_date},新增 ${result.created} 条,积分 ${money(result.total_points)}`);
await load();
} catch (error) {
showError(error);
}
}
async function onConfirm(id) {
const hash = document.querySelector(`[data-hash="${id}"]`).value.trim();
if (!hash) return showToast("请先填写交易哈希");
await run(() => api(`/withdrawals/${id}/confirm`, { method: "POST", body: JSON.stringify({ tx_hash: hash }) }), "提现已确认");
}
async function onReject(id) {
await run(() => api(`/withdrawals/${id}/reject`, { method: "POST", body: JSON.stringify({ note: "控制台拒绝" }) }), "提现已拒绝");
}
async function openNode(sn) {
const node = state.nodes.find((item) => item.sn === sn);
if (!node) return;
let yields = [];
try {
yields = await api(`/nodes/${encodeURIComponent(sn)}/yields`);
} catch (error) {
showError(error);
}
const mask = document.createElement("div");
mask.className = "mask";
mask.innerHTML = `
<aside class="drawer">
<header>
<div>
<p class="eyebrow">节点</p>
<h2>${esc(node.sn)}</h2>
</div>
<button class="ghost" type="button" data-close>关闭</button>
</header>
<div style="margin-top:16px">${pill(node.status)}</div>
<div class="kv">
<span>钱包</span><b class="mono">${esc(node.wallet_address)}</b>
<span>购买</span><b>${esc(money(node.purchase_amount))}</b>
<span>开始</span><b>${esc(when(node.licensed_at))}</b>
<span>到期</span><b>${esc(when(node.license_expires_at))}</b>
<span>心跳</span><b>${esc(when(node.last_heartbeat_at))}</b>
</div>
<button class="gold" type="button" id="beat">发送心跳</button>
<h2 style="margin-top:28px">最近流水</h2>
<div class="table-wrap" style="margin-top:12px;box-shadow:none">
<table>
<thead><tr><th>日期</th><th>积分</th><th>在线率</th></tr></thead>
<tbody>${yields.slice(0, 6).map((row) => `<tr><td class="mono">${esc(row.yield_date)}</td><td>${esc(money(row.points_delta))}</td><td>${esc(pct(row.online_rate))}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">暂无流水</td></tr>`}</tbody>
</table>
</div>
</aside>
`;
mask.addEventListener("click", (event) => {
if (event.target === mask || event.target.closest("[data-close]")) mask.remove();
});
mask.querySelector("#beat").addEventListener("click", async () => {
await run(() => api("/node/heartbeat", { method: "POST", body: JSON.stringify({ sn }) }), "心跳已记录");
mask.remove();
});
document.body.appendChild(mask);
}
async function run(action, message) {
try {
await action();
showToast(message);
await load();
} catch (error) {
showError(error);
}
}
async function api(path, options = {}) {
const res = await fetch(`/api/v1${path}`, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(errorText(data));
return data;
}
function errorText(data) {
const detail = data && data.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) return detail.map((item) => item.msg).join(";");
return "请求失败";
}
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => toastEl.classList.remove("show"), 2600);
}
function showError(error) {
showToast(error.message || "操作失败");
}
function money(value) {
if (value == null || value === "") return "0";
const raw = String(value);
const neg = raw.startsWith("-");
const [whole, frac = ""] = raw.replace("-", "").split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const trimmed = frac.replace(/0+$/, "");
return `${neg ? "-" : ""}${trimmed ? `${grouped}.${trimmed}` : grouped}`;
}
function pct(value) {
const number = Number(value);
if (!Number.isFinite(number)) return "—";
return `${(number * 100).toFixed(1)}%`;
}
function when(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function day(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(value));
}
function short(value) {
const text = String(value || "");
return text.length > 16 ? `${text.slice(0, 8)}…${text.slice(-4)}` : text;
}
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[char]));
}
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HyperNode · 算力控制面</title>
<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" />
<link rel="stylesheet" href="/static/styles.css?v=2" />
</head>
<body>
<div class="shell">
<aside class="rail">
<a class="brand" href="#overview">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<path d="M16 3 L27 9.5 V22.5 L16 29 L5 22.5 V9.5 Z" fill="none" stroke="currentColor" stroke-width="1.4"/>
<circle cx="16" cy="16" r="2.2" fill="currentColor"/>
<path d="M16 8 V14 M16 18 V24 M10 12.5 L14.2 15 M17.8 17 L22 19.5" stroke="currentColor" stroke-width="1.2"/>
</svg>
</span>
<span>
<strong>HyperNode</strong>
<em>Compute Atelier</em>
</span>
</a>
<nav class="nav" id="nav">
<button type="button" data-view="overview">总览</button>
<button type="button" data-view="nodes">节点</button>
<button type="button" data-view="ledger">流水</button>
<button type="button" data-view="cash">提现</button>
</nav>
<div class="rail-foot">
<span id="health-dot" class="dot"></span>
<span id="health-text">正在连接</span>
</div>
</aside>
<div class="stage">
<header class="top">
<div>
<p class="eyebrow" id="eyebrow">控制面</p>
<h1 id="title">总览</h1>
</div>
<div class="top-meta">
<div class="clock">
<span>北京时间</span>
<strong id="clock">--:--:--</strong>
</div>
<button type="button" class="ghost" id="refresh">刷新</button>
</div>
</header>
<main id="view" class="view"></main>
</div>
</div>
<div id="toast" class="toast" role="status"></div>
<script src="/static/app.js?v=2"></script>
</body>
</html>
+359
View File
@@ -0,0 +1,359 @@
:root {
--bg: #08090d;
--ink: #f4efe6;
--muted: #a3988a;
--gold: #e0c07a;
--gold-deep: #a88345;
--line: rgba(224, 192, 122, 0.18);
--panel: rgba(16, 18, 24, 0.82);
--panel-solid: #12141b;
--online: #8ed0b5;
--offline: #d08a78;
--danger: #e09888;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
--serif: "Fraunces", "Palatino Linotype", Palatino, serif;
--sans: "Manrope", "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--ink);
font-family: var(--sans);
background:
radial-gradient(980px 520px at 0% -10%, rgba(224, 192, 122, 0.16), transparent 58%),
radial-gradient(760px 480px at 100% 0%, rgba(126, 164, 196, 0.12), transparent 52%),
var(--bg);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(244, 239, 230, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(244, 239, 230, 0.035) 1px, transparent 1px);
background-size: 84px 84px;
mask-image: radial-gradient(circle at 20% 10%, #000 0%, transparent 72%);
}
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, p { margin: 0; }
.shell {
min-height: 100%;
display: grid;
grid-template-columns: 248px 1fr;
}
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 20px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: rgba(8, 9, 13, 0.72);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
gap: 12px;
align-items: center;
color: inherit;
text-decoration: none;
}
.mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
color: var(--gold);
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, rgba(224, 192, 122, 0.16), transparent);
}
.brand strong {
display: block;
font-family: var(--serif);
font-size: 22px;
font-weight: 560;
letter-spacing: -0.03em;
}
.brand em {
display: block;
margin-top: 2px;
color: var(--muted);
font-style: normal;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.nav {
display: grid;
gap: 6px;
margin-top: 36px;
}
.nav button {
text-align: left;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 12px;
padding: 12px 14px;
letter-spacing: 0.04em;
}
.nav button.active,
.nav button:hover {
color: var(--ink);
background: rgba(224, 192, 122, 0.08);
}
.nav button.active { box-shadow: inset 2px 0 0 var(--gold); }
.rail-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.on {
background: var(--online);
box-shadow: 0 0 0 4px rgba(142, 208, 181, 0.15);
}
.stage { min-width: 0; padding: 32px 40px 56px; }
.top {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.eyebrow {
color: var(--gold);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
h1 {
margin-top: 6px;
font-family: var(--serif);
font-size: 48px;
font-weight: 560;
letter-spacing: -0.04em;
}
.top-meta { display: flex; align-items: center; gap: 16px; }
.clock {
text-align: right;
}
.clock span {
display: block;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.14em;
}
.clock strong {
font-family: var(--mono);
font-size: 20px;
font-weight: 500;
}
.ghost, .gold, .texty {
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
padding: 10px 16px;
}
.ghost:hover, .texty:hover { border-color: var(--gold); }
.gold {
border-color: transparent;
background: linear-gradient(180deg, #f0d7a2, #c4964e);
color: #24180b;
font-weight: 700;
}
.gold:hover { filter: brightness(1.05); }
.texty { padding: 6px 10px; color: var(--gold); }
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric, .panel, .table-wrap, .drawer {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.metric { padding: 22px 22px 18px; }
.metric span {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 14px;
font-family: var(--serif);
font-size: 40px;
font-weight: 560;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.metric em {
display: block;
margin-top: 8px;
color: var(--muted);
font-style: normal;
font-size: 13px;
}
.layout, .forms {
display: grid;
gap: 16px;
margin-top: 16px;
align-items: start;
}
.layout { grid-template-columns: 1.4fr 0.8fr; }
.forms { grid-template-columns: 1fr 1fr; }
.panel { padding: 22px; }
.panel h2 {
font-family: var(--serif);
font-size: 28px;
font-weight: 560;
letter-spacing: -0.03em;
}
.sub {
margin: 6px 0 18px;
color: var(--muted);
font-size: 14px;
}
.stack { display: grid; gap: 14px; }
label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); }
input, select {
width: 100%;
border: 1px solid var(--line);
background: rgba(8, 9, 13, 0.55);
border-radius: 12px;
padding: 12px 14px;
outline: none;
}
input:focus, select:focus { border-color: var(--gold); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 14px 16px; text-align: left; border-bottom: 1px solid rgba(224, 192, 122, 0.08); font-size: 14px; }
th {
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
td input { width: 148px; padding: 8px 10px; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: rgba(224, 192, 122, 0.04); }
.mono { font-family: var(--mono); font-size: 13px; }
.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 { color: var(--online); }
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
.pill.pending { color: var(--gold); }
.pill.completed, .pill.settled { color: var(--online); }
.empty {
padding: 48px 20px;
text-align: center;
color: var(--muted);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: end;
margin-bottom: 16px;
}
.toolbar .grow { min-width: 240px; flex: 1; }
.mask {
position: fixed;
inset: 0;
background: rgba(5, 6, 8, 0.62);
display: grid;
justify-items: end;
z-index: 5;
}
.drawer {
width: min(460px, 100%);
height: 100%;
border-radius: 0;
border-right: 0;
padding: 28px;
overflow: auto;
background: #101218;
}
.drawer header { display: flex; justify-content: space-between; align-items: start; gap: 12px; }
.kv { display: grid; grid-template-columns: 88px 1fr; gap: 8px 12px; margin: 22px 0; font-size: 14px; }
.kv span { color: var(--muted); }
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
opacity: 0;
pointer-events: none;
background: #1b1e27;
border: 1px solid var(--line);
color: var(--ink);
padding: 12px 16px;
border-radius: 999px;
transition: 0.2s ease;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 1100px) {
.metrics, .forms { grid-template-columns: 1fr 1fr; }
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
.rail {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.nav { grid-template-columns: repeat(4, 1fr); margin-top: 18px; }
.rail-foot { display: none; }
.stage { padding: 22px 16px 40px; }
h1 { font-size: 36px; }
.metrics, .layout, .forms { grid-template-columns: 1fr; }
.top { align-items: start; }
}
+1
View File
@@ -23,6 +23,7 @@ python -m app
| `app/jobs/daily_yield.py` | 凌晨清算入口 | | `app/jobs/daily_yield.py` | 凌晨清算入口 |
| `app/scheduler.py` | 进程内定时任务 | | `app/scheduler.py` | 进程内定时任务 |
| `app/settle.py` | 可单独执行的清算命令 | | `app/settle.py` | 可单独执行的清算命令 |
| `web/` | 控制台页面,服务启动后打开 `/` |
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` | | `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
## 数据表 ## 数据表
+2
View File
@@ -60,6 +60,8 @@ journalctl -u hypernode.service -f
curl http://127.0.0.1:33333/health curl http://127.0.0.1:33333/health
``` ```
控制台:`http://<服务器 IP>:33333/`
接口文档:`http://<服务器 IP>:33333/docs` 接口文档:`http://<服务器 IP>:33333/docs`
手动补跑某一天的清算: 手动补跑某一天的清算: