Skip to content

Commit 80de5d8

Browse files
committed
Token 用量统计;Docker 化 + GHCR 自动构建 + 版本信息展示 (v1.1.0)
- Sub2API 站点显示今日 tokens 与请求数(来自站点仪表盘接口), 总览「今日总消耗」卡片附带全站合计,站点行与趋势弹窗同步展示 - Dockerfile(node:22-alpine,数据卷 /app/data,健康检查),.dockerignore - GitHub Actions:推送 main 自动构建镜像发布到 ghcr.io(latest + commit SHA 双标签) - deploy/docker-compose.yml:面板 + Watchtower(每 5 分钟检查更新并清理旧镜像) - 页面展示运行版本:侧栏与设置页「关于」显示 vX.Y.Z 与构建 commit
1 parent 16f33d9 commit 80de5d8

9 files changed

Lines changed: 140 additions & 8 deletions

File tree

.dockerignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
data
3+
.git
4+
.github
5+
.claude
6+
.DS_Store
7+
Dockerfile
8+
docker-compose.yml
9+
deploy

.github/workflows/docker.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Build & Push Docker image
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
packages: write
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: docker/setup-buildx-action@v3
19+
20+
- name: Login to GHCR
21+
uses: docker/login-action@v3
22+
with:
23+
registry: ghcr.io
24+
username: ${{ github.actor }}
25+
password: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Build and push
28+
uses: docker/build-push-action@v6
29+
with:
30+
context: .
31+
push: true
32+
build-args: |
33+
GIT_SHA=${{ github.sha }}
34+
tags: |
35+
ghcr.io/${{ github.repository }}:latest
36+
ghcr.io/${{ github.repository }}:${{ github.sha }}
37+
cache-from: type=gha
38+
cache-to: type=gha,mode=max

Dockerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# 中转站余额监控面板
2+
FROM node:22-alpine
3+
4+
WORKDIR /app
5+
ENV NODE_ENV=production \
6+
HOST=0.0.0.0 \
7+
PORT=8787
8+
9+
COPY package.json package-lock.json ./
10+
RUN npm ci --omit=dev
11+
12+
COPY . .
13+
14+
# 构建时注入 git commit,页面「关于」与侧栏显示
15+
ARG GIT_SHA=dev
16+
ENV APP_COMMIT=$GIT_SHA
17+
18+
# 运行时数据(站点凭证 / 历史 / 会话密钥)挂载到宿主机持久化
19+
VOLUME /app/data
20+
21+
EXPOSE 8787
22+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
23+
CMD wget -q --spider http://127.0.0.1:8787/ || exit 1
24+
25+
CMD ["node", "server.js"]

deploy/docker-compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 服务器部署:面板 + Watchtower 自动更新
2+
# 用法:mkdir -p ~/relay-monitor && cp docker-compose.yml ~/relay-monitor/ && cd ~/relay-monitor && docker compose up -d
3+
services:
4+
relay-monitor:
5+
image: ghcr.io/lettimepassby/relay-monitor:latest
6+
container_name: relay-monitor
7+
restart: unless-stopped
8+
ports:
9+
- "8787:8787"
10+
environment:
11+
- HOST=0.0.0.0
12+
volumes:
13+
- ./data:/app/data
14+
15+
watchtower:
16+
image: containrrr/watchtower
17+
container_name: watchtower
18+
restart: unless-stopped
19+
volumes:
20+
- /var/run/docker.sock:/var/run/docker.sock
21+
# 每 5 分钟检查一次 relay-monitor 的新镜像,更新后清理旧镜像
22+
command: --cleanup --interval 300 relay-monitor

lib/providers.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ async function querySub2Api(station) {
264264

265265
// 今日消耗:读取站点用户仪表盘同款接口(today_actual_cost = 今日实际扣费),
266266
// 与站点页面显示完全一致;老版本没有该接口时静默降级为历史推算
267-
let todayUsed = null, todayRequests = null;
267+
let todayUsed = null, todayRequests = null, todayTokens = null;
268268
try {
269269
const st = await request(`${base}/api/v1/usage/dashboard/stats`, {
270270
headers: { Authorization: `Bearer ${bearerOf()}` },
@@ -274,11 +274,12 @@ async function querySub2Api(station) {
274274
if (Number.isFinite(Number(d.today_actual_cost))) {
275275
todayUsed = round2(Number(d.today_actual_cost));
276276
todayRequests = Number.isFinite(Number(d.today_requests)) ? Number(d.today_requests) : null;
277+
todayTokens = Number.isFinite(Number(d.today_tokens)) ? Number(d.today_tokens) : null;
277278
}
278279
}
279280
} catch { /* 不影响余额查询 */ }
280281

281-
return { ...parseSub2ApiMe(data), todayUsed, todayRequests, tokensChanged };
282+
return { ...parseSub2ApiMe(data), todayUsed, todayRequests, todayTokens, tokensChanged };
282283
}
283284

284285
const HANDLERS = {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ document.getElementById("themeToggle").onclick = () =>
2121
const state = {
2222
stations: [], settings: { refreshIntervalSec: 60, lowBalanceUsd: 5 },
2323
types: [], channelTypes: [], channels: [], rules: {},
24-
view: "dashboard", user: null,
24+
view: "dashboard", user: null, app: null,
2525
trendHours: 24, overview: null, // 总览趋势图的时间范围与数据缓存 {hours, series, at}
2626
};
2727
let autoTimer = null;
@@ -68,6 +68,13 @@ const api = {
6868
const $ = (s) => document.querySelector(s);
6969
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
7070
const usd = (n) => "$" + Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
71+
const fmtTokens = (n) => {
72+
n = Number(n) || 0;
73+
if (n >= 1e9) return +(n / 1e9).toFixed(1) + "B";
74+
if (n >= 1e6) return +(n / 1e6).toFixed(1) + "M";
75+
if (n >= 1e3) return +(n / 1e3).toFixed(1) + "K";
76+
return String(n);
77+
};
7178
const typeLabel = (v) => (state.types.find((t) => t.value === v)?.label || v);
7279

7380
function threshold(s) {
@@ -180,6 +187,7 @@ function stationRow(s) {
180187
const pieces = [];
181188
if (b && b.ok && s.todayUsed != null) {
182189
pieces.push(`<span>今日消耗 ${s.todayIsEstimate ? "≈" : ""}${usd(s.todayUsed)}</span>`);
190+
if (s.todayTokens != null) pieces.push(`<span>${fmtTokens(s.todayTokens)} tokens</span>`);
183191
}
184192
if (eta) pieces.push(`<span class="${eta.cls}">${eta.text}</span>`);
185193
if (pieces.length) pieces.push("<span>点击查看趋势</span>");
@@ -221,11 +229,19 @@ function renderDashboard() {
221229
const lowCount = list.filter((s) => ["warn", "danger"].includes(statusOf(s))).length;
222230
const errCount = list.filter((s) => statusOf(s) === "error").length;
223231

232+
// 今日 tokens / 请求数:只有 sub2api 站点能提供,有数据才显示
233+
const tokList = list.filter((s) => s.todayTokens != null);
234+
const reqList = list.filter((s) => s.todayRequests != null);
235+
const subBits = [];
236+
if (tokList.length) subBits.push(`${fmtTokens(tokList.reduce((a, s) => a + s.todayTokens, 0))} tokens`);
237+
if (reqList.length) subBits.push(`${reqList.reduce((a, s) => a + s.todayRequests, 0).toLocaleString("en-US")} 次请求`);
238+
const todaySub = subBits.length ? `<div class="stat-sub">${subBits.join(" · ")}</div>` : "";
239+
224240
$("#headerActions").innerHTML = HDR_BTNS;
225241
const stats = `
226242
<div class="stats stats-5">
227243
<div class="stat-card"><div class="label">总剩余余额</div><div class="value">${usd(totalRemaining)}</div></div>
228-
<div class="stat-card"><div class="label">今日总消耗</div><div class="value">${todayApprox ? "≈ " : ""}${usd(todayTotal)}</div></div>
244+
<div class="stat-card"><div class="label">今日总消耗</div><div class="value">${todayApprox ? "≈ " : ""}${usd(todayTotal)}</div>${todaySub}</div>
229245
<div class="stat-card"><div class="label">日均消耗(估算)</div><div class="value">${totalBurn > 0 ? usd(totalBurn) : "—"}</div></div>
230246
<div class="stat-card"><div class="label">低余额 / 耗尽</div><div class="value ${lowCount ? "warn" : ""}">${lowCount}<small>个</small></div></div>
231247
<div class="stat-card"><div class="label">查询异常</div><div class="value ${errCount ? "danger" : ""}">${errCount}<small>个</small></div></div>
@@ -570,7 +586,7 @@ function renderSettings() {
570586
</div>
571587
<div class="section-head" style="margin-top:20px"><h2>关于</h2></div>
572588
<div class="panel"><div class="st-row"><div class="st-main" style="cursor:default">
573-
<div class="st-name">中转站余额监控</div>
589+
<div class="st-name">中转站余额监控${state.app ? ` <span class="demo-tag">v${esc(state.app.version)}${state.app.commit ? " · " + esc(state.app.commit) : ""}</span>` : ""}</div>
574590
<div class="st-meta">支持 New API(访问令牌 / sk 密钥)与 Sub2API(登录令牌 / 账号密码自动续期)。界面基于 app-shell-ui 设计语言构建。凭证仅存储于本机 data/ 目录。</div>
575591
</div></div></div>`;
576592

@@ -609,7 +625,8 @@ function render() {
609625
else if (state.view === "stations") renderStations();
610626
else if (state.view === "notify") renderNotify();
611627
else renderSettings();
612-
$("#lastSync").textContent = "自动刷新:每 " + state.settings.refreshIntervalSec + " 秒";
628+
const ver = state.app ? `v${state.app.version}${state.app.commit ? ` (${state.app.commit})` : ""}` : "";
629+
$("#lastSync").innerHTML = `自动刷新:每 ${state.settings.refreshIntervalSec}${ver ? `<br>${esc(ver)}` : ""}`;
613630
}
614631

615632
// ---- 中转站弹窗 --------------------------------------------------------------
@@ -768,7 +785,13 @@ async function openTrend(station) {
768785
const eta = etaText(prediction);
769786
$("#trendStats").innerHTML = `
770787
<div class="stat-card"><div class="label">当前余额</div><div class="value">${b?.ok ? usd(b.remaining) : "—"}</div></div>
771-
<div class="stat-card"><div class="label">今日消耗</div><div class="value">${station.todayUsed != null ? (station.todayIsEstimate ? "≈ " : "") + usd(station.todayUsed) : "—"}</div></div>
788+
<div class="stat-card"><div class="label">今日消耗</div><div class="value">${station.todayUsed != null ? (station.todayIsEstimate ? "≈ " : "") + usd(station.todayUsed) : "—"}</div>${
789+
station.todayTokens != null || station.todayRequests != null
790+
? `<div class="stat-sub">${[
791+
station.todayTokens != null ? fmtTokens(station.todayTokens) + " tokens" : null,
792+
station.todayRequests != null ? station.todayRequests.toLocaleString("en-US") + " 次" : null,
793+
].filter(Boolean).join(" · ")}</div>` : ""
794+
}</div>
772795
<div class="stat-card"><div class="label">日均消耗(估算)</div><div class="value">${prediction?.burnPerDay > 0 ? usd(prediction.burnPerDay) : "—"}</div></div>
773796
<div class="stat-card"><div class="label">预计耗尽</div><div class="value ${eta?.cls || ""}">${prediction?.etaDays != null ? fmtEtaText(prediction.etaDays) : "—"}</div></div>`;
774797
drawChart($("#trendChart"), points, prediction);
@@ -1041,6 +1064,7 @@ async function bootData() {
10411064
state.channelTypes = meta.channelTypes;
10421065
state.settings = meta.settings;
10431066
state.rules = meta.rules;
1067+
state.app = meta.app || null;
10441068
await Promise.all([reload(), loadNotifications()]);
10451069
render();
10461070
startAuto();

public/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ button { font-family: inherit; cursor: pointer; }
122122
.stat-card .value small { font-size: 12px; font-weight: 500; color: var(--text-tertiary); margin-left: 2px; }
123123
.stat-card .value.warn { color: var(--text-warning); }
124124
.stat-card .value.danger { color: var(--text-danger); }
125+
.stat-card .stat-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 3px; font-variant-numeric: tabular-nums; }
125126

126127
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
127128
.section-head h2 { font-size: 14.5px; font-weight: 600; }

server.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from "express";
22
import { fileURLToPath } from "node:url";
33
import { dirname, join } from "node:path";
4+
import { readFile } from "node:fs/promises";
45

56
import { Store } from "./lib/store.js";
67
import { queryStation, STATION_TYPES } from "./lib/providers.js";
@@ -13,6 +14,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1314
const PORT = process.env.PORT || 8787;
1415
const HOST = process.env.HOST || "127.0.0.1";
1516

17+
// 版本信息:版本号来自 package.json,commit 由 Docker 构建时注入(APP_COMMIT)
18+
const pkg = JSON.parse(await readFile(join(__dirname, "package.json"), "utf8"));
19+
const APP_INFO = {
20+
version: pkg.version,
21+
commit: (process.env.APP_COMMIT || "").slice(0, 7) || null,
22+
};
23+
1624
const store = new Store(join(__dirname, "data", "stations.json"));
1725
await store.load();
1826
const history = await new History(join(__dirname, "data", "history.json")).load();
@@ -150,6 +158,7 @@ mock.get("/sub2api/:acc/api/v1/usage/dashboard/stats", (req, res) => {
150158
today_actual_cost: Number(todayCost.toFixed(4)),
151159
today_cost: Number((todayCost * 1.15).toFixed(4)),
152160
today_requests: Math.round(todayCost * 40),
161+
today_tokens: Math.round(todayCost * 250000),
153162
total_actual_cost: Number(s.usedUsd.toFixed(4)),
154163
},
155164
});
@@ -277,6 +286,7 @@ app.get("/api/meta", (req, res) => {
277286
channelTypes: CHANNEL_TYPES,
278287
settings: store.settings,
279288
rules: store.rules,
289+
app: APP_INFO,
280290
});
281291
});
282292

@@ -417,6 +427,8 @@ function redact(s) {
417427
prediction: history.predict(s.id),
418428
todayUsed: fromSite ?? history.usedSince(s.id, midnight.getTime()),
419429
todayIsEstimate: fromSite == null,
430+
todayTokens: s.balance?.todayTokens ?? null,
431+
todayRequests: s.balance?.todayRequests ?? null,
420432
};
421433
}
422434

0 commit comments

Comments
 (0)