dsh-remote-plugin 0.5.6 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +427 -0
- package/gateway.cjs +104 -0
- package/index.mjs +63 -0
- package/package.json +2 -1
- package/public/admin.html +53 -0
- package/public/admin.js +95 -0
- package/public/app.js +88 -1
- package/public/index.html +24 -2
- package/public/styles.css +26 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/* DSH Remote 统计核心 —— 零依赖
|
|
2
|
+
*
|
|
3
|
+
* 事件来源:
|
|
4
|
+
* - 插件进程实时监听 DSH 'session/event' (assistant/message + usage) -> POST /stats/ingest
|
|
5
|
+
* - 网关扫描 ~/.dsh/sessions 下的 session.jsonl.zstd 历史回填(启动全量 + 定期增量)
|
|
6
|
+
*
|
|
7
|
+
* 聚合:
|
|
8
|
+
* time(UTC 毫秒) -> Asia/Shanghai(固定 UTC+8, 无夏令时) -> 日 × 小时 × 模型
|
|
9
|
+
* 时段: 高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 其余空闲
|
|
10
|
+
* 四桶: input(未缓存输入) / cacheRead(缓存命中读) / cacheWrite(缓存写) / output(输出)
|
|
11
|
+
*
|
|
12
|
+
* 存储:
|
|
13
|
+
* ~/.dsh-remote/stats/days/YYYY-MM-DD.json 按天一个文件, 内含 hours
|
|
14
|
+
* ~/.dsh-remote/stats/cursors.json 每个 session 已处理的最大 seq(幂等游标)
|
|
15
|
+
*
|
|
16
|
+
* 费用: 固定价格表(元 / 百万 tokens), v2 将改为可配置。
|
|
17
|
+
*/
|
|
18
|
+
'use strict'
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs')
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
const os = require('node:os')
|
|
23
|
+
const { spawn } = require('node:child_process')
|
|
24
|
+
const readline = require('node:readline')
|
|
25
|
+
|
|
26
|
+
// ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
|
|
27
|
+
// 时段判定: 北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点)
|
|
28
|
+
const PEAK_HOURS = [[9, 12], [14, 18]]
|
|
29
|
+
const PRICES = {
|
|
30
|
+
'deepseek-v4-flash': {
|
|
31
|
+
inputCacheHit: { peak: 0.10, off: 0.05 },
|
|
32
|
+
inputMiss: { peak: 3.0, off: 1.5 },
|
|
33
|
+
output: { peak: 9.0, off: 4.5 },
|
|
34
|
+
},
|
|
35
|
+
'deepseek-v4-pro': {
|
|
36
|
+
inputCacheHit: { peak: 0.30, off: 0.15 },
|
|
37
|
+
inputMiss: { peak: 9.0, off: 4.5 },
|
|
38
|
+
output: { peak: 27.0, off: 13.5 },
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
// 缓存写入按未命中输入价计费(DeepSeek 缓存写入以未命中输入计费)
|
|
42
|
+
const BUCKET_PRICE_KEY = {
|
|
43
|
+
input: 'inputMiss',
|
|
44
|
+
cacheRead: 'inputCacheHit',
|
|
45
|
+
cacheWrite: 'inputMiss',
|
|
46
|
+
output: 'output',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BJ_OFFSET_MS = 8 * 3600 * 1000
|
|
50
|
+
// 定价生效日(北京时间): 2026-08-17 零点更新, 此前的 token 不计入费用统计
|
|
51
|
+
const PRICING_START_DATE = '2026-08-17'
|
|
52
|
+
const DAYS_DIR = 'days'
|
|
53
|
+
const CURSORS_FILE = 'cursors.json'
|
|
54
|
+
|
|
55
|
+
function pad2(n) { return String(n).padStart(2, '0') }
|
|
56
|
+
|
|
57
|
+
/** UTC 毫秒 -> 北京小时(0-23)。固定 UTC+8, 不随服务器本地时区。 */
|
|
58
|
+
function beijingHour(timeMs) {
|
|
59
|
+
return new Date(timeMs + BJ_OFFSET_MS).getUTCHours()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** UTC 毫秒 -> 北京自然日 'YYYY-MM-DD'。 */
|
|
63
|
+
function beijingDate(timeMs) {
|
|
64
|
+
const d = new Date(timeMs + BJ_OFFSET_MS)
|
|
65
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 北京小时 -> 时段。 */
|
|
69
|
+
function periodOfHour(hour) {
|
|
70
|
+
for (const [start, end] of PEAK_HOURS) {
|
|
71
|
+
if (hour >= start && hour < end) return 'peak'
|
|
72
|
+
}
|
|
73
|
+
return 'off'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 时段单价; 未知模型返回全 0(统计照记, 费用为 0)。 */
|
|
77
|
+
function pricesFor(model) {
|
|
78
|
+
return PRICES[model] || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emptyBucket() {
|
|
82
|
+
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cost: 0 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addTokens(bucket, key, tokens) {
|
|
86
|
+
if (typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0) {
|
|
87
|
+
bucket[key] += tokens
|
|
88
|
+
}
|
|
89
|
+
return bucket
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 把一个 usage 事件累计进 bucket(含费用)。 */
|
|
93
|
+
function addUsage(bucket, model, period, usage) {
|
|
94
|
+
const prices = pricesFor(model)
|
|
95
|
+
const usageObj = usage || {}
|
|
96
|
+
for (const key of ['input', 'cacheRead', 'cacheWrite', 'output']) {
|
|
97
|
+
const tokens = usageObj[key]
|
|
98
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) continue
|
|
99
|
+
bucket[key] += tokens
|
|
100
|
+
if (prices) {
|
|
101
|
+
const priceKey = BUCKET_PRICE_KEY[key]
|
|
102
|
+
bucket.cost += tokens / 1e6 * prices[priceKey][period]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return bucket
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergeBucket(dst, src) {
|
|
109
|
+
for (const key of ['input', 'cacheRead', 'cacheWrite', 'output', 'cost']) {
|
|
110
|
+
dst[key] += src[key]
|
|
111
|
+
}
|
|
112
|
+
return dst
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 时段事件 -> 日/小时/模型聚合的 key。 */
|
|
116
|
+
function eventKey(timeMs) {
|
|
117
|
+
return { date: beijingDate(timeMs), hour: beijingHour(timeMs), period: periodOfHour(beijingHour(timeMs)) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 事件里的模型: 优先 message.source.model。 */
|
|
121
|
+
function eventModel(event) {
|
|
122
|
+
try {
|
|
123
|
+
const m = event?.data?.message?.source?.model
|
|
124
|
+
if (typeof m === 'string' && m) return m
|
|
125
|
+
} catch {}
|
|
126
|
+
return ''
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeUsage(event) {
|
|
130
|
+
const u = event?.data?.usage
|
|
131
|
+
if (!u || typeof u !== 'object') return null
|
|
132
|
+
return {
|
|
133
|
+
input: typeof u.inputTokens === 'number' && Number.isFinite(u.inputTokens) ? u.inputTokens : 0,
|
|
134
|
+
cacheRead: typeof u.cacheReadTokens === 'number' && Number.isFinite(u.cacheReadTokens) ? u.cacheReadTokens : 0,
|
|
135
|
+
cacheWrite: typeof u.cacheWriteTokens === 'number' && Number.isFinite(u.cacheWriteTokens) ? u.cacheWriteTokens : 0,
|
|
136
|
+
output: typeof u.outputTokens === 'number' && Number.isFinite(u.outputTokens) ? u.outputTokens : 0,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 汇总一天(hours) -> peak/off/total。 */
|
|
141
|
+
function summarizeDay(day) {
|
|
142
|
+
const peak = emptyBucket()
|
|
143
|
+
const off = emptyBucket()
|
|
144
|
+
for (const hourStr of Object.keys(day?.hours || {})) {
|
|
145
|
+
const hour = Number(hourStr)
|
|
146
|
+
const target = periodOfHour(hour) === 'peak' ? peak : off
|
|
147
|
+
for (const model of Object.keys(day.hours[hourStr] || {})) {
|
|
148
|
+
mergeBucket(target, day.hours[hourStr][model])
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const total = emptyBucket()
|
|
152
|
+
mergeBucket(total, peak)
|
|
153
|
+
mergeBucket(total, off)
|
|
154
|
+
return { peak, off, total }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function dayTotals(day) {
|
|
158
|
+
const s = summarizeDay(day)
|
|
159
|
+
return s
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function tokenKeyName(key) {
|
|
163
|
+
return { input: 'input', cacheRead: 'cacheRead', cacheWrite: 'cacheWrite', output: 'output' }[key] || key
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 统计存储: 单文件按天 + 游标。写操作由网关单进程调用, 内部用同步队列串行化。 */
|
|
167
|
+
class StatsStore {
|
|
168
|
+
constructor(dir) {
|
|
169
|
+
this.dir = dir || path.join(os.homedir(), '.dsh-remote', 'stats')
|
|
170
|
+
this.daysDir = path.join(this.dir, DAYS_DIR)
|
|
171
|
+
this.cursorsFile = path.join(this.dir, CURSORS_FILE)
|
|
172
|
+
this.cursors = null
|
|
173
|
+
this.queue = Promise.resolve()
|
|
174
|
+
fs.mkdirSync(this.daysDir, { recursive: true })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_dayFile(date) { return path.join(this.daysDir, `${date}.json`) }
|
|
178
|
+
|
|
179
|
+
_loadDay(date) {
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(fs.readFileSync(this._dayFile(date), 'utf8'))
|
|
182
|
+
} catch {
|
|
183
|
+
return { date, hours: {} }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_saveDay(day) {
|
|
188
|
+
const file = this._dayFile(day.date)
|
|
189
|
+
const tmp = file + '.tmp'
|
|
190
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
191
|
+
fs.writeFileSync(tmp, JSON.stringify(day))
|
|
192
|
+
fs.renameSync(tmp, file)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_loadCursors() {
|
|
196
|
+
if (this.cursors) return this.cursors
|
|
197
|
+
try {
|
|
198
|
+
this.cursors = JSON.parse(fs.readFileSync(this.cursorsFile, 'utf8'))
|
|
199
|
+
} catch {
|
|
200
|
+
this.cursors = {}
|
|
201
|
+
}
|
|
202
|
+
return this.cursors
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_saveCursors() {
|
|
206
|
+
const tmp = this.cursorsFile + '.tmp'
|
|
207
|
+
fs.mkdirSync(path.dirname(this.cursorsFile), { recursive: true })
|
|
208
|
+
fs.writeFileSync(tmp, JSON.stringify(this.cursors))
|
|
209
|
+
fs.renameSync(tmp, this.cursorsFile)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_cursor(sessionId) {
|
|
213
|
+
const c = this._loadCursors()
|
|
214
|
+
const cur = c[sessionId]
|
|
215
|
+
if (cur && typeof cur.lastSeq === 'number') return cur
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_setCursor(sessionId, lastSeq) {
|
|
220
|
+
const c = this._loadCursors()
|
|
221
|
+
c[sessionId] = { lastSeq, updatedAt: Date.now() }
|
|
222
|
+
this._saveCursors()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 在串行队列里执行统计写操作。 */
|
|
226
|
+
_enqueue(fn) {
|
|
227
|
+
const run = this.queue.then(fn)
|
|
228
|
+
this.queue = run.catch(() => {})
|
|
229
|
+
return run
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 处理单个 session 事件(已过滤为 assistant/message 且 usage 有效)。
|
|
234
|
+
* - seq <= cursor: 重复, 跳过
|
|
235
|
+
* - seq === cursor+1: 正常聚合
|
|
236
|
+
* - seq > cursor+1: gap, 不聚合, 等待扫描补漏
|
|
237
|
+
*/
|
|
238
|
+
processEvent(sessionId, event, fallbackModel) {
|
|
239
|
+
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
240
|
+
const time = typeof event.time === 'number' ? event.time : Date.now()
|
|
241
|
+
const usage = normalizeUsage(event)
|
|
242
|
+
if (!usage || seq < 0) return { processed: false, gap: false, skip: true }
|
|
243
|
+
const cur = this._cursor(sessionId)
|
|
244
|
+
const lastSeq = cur ? cur.lastSeq : -1
|
|
245
|
+
if (seq <= lastSeq) return { processed: false, gap: false, skip: true }
|
|
246
|
+
if (seq > lastSeq + 1) return { processed: false, gap: true, skip: true }
|
|
247
|
+
|
|
248
|
+
const model = eventModel(event) || fallbackModel || 'unknown'
|
|
249
|
+
const { date, hour, period } = eventKey(time)
|
|
250
|
+
if (date < PRICING_START_DATE) return { processed: false, gap: false, skip: true }
|
|
251
|
+
const day = this._loadDay(date)
|
|
252
|
+
const hourBucket = day.hours[hour] || (day.hours[hour] = {})
|
|
253
|
+
const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
|
|
254
|
+
addUsage(modelBucket, model, period, usage)
|
|
255
|
+
this._saveDay(day)
|
|
256
|
+
this._setCursor(sessionId, seq)
|
|
257
|
+
return { processed: true, gap: false, skip: false }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 串行化版本(HTTP ingest 用)。 */
|
|
261
|
+
ingestEvent(sessionId, event, fallbackModel) {
|
|
262
|
+
return this._enqueue(() => this.processEvent(sessionId, event, fallbackModel))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 扫描一个 zstd 会话文件, 从游标后顺序处理。返回处理的事件数。
|
|
267
|
+
* 用系统 zstd 命令解压(项目约束: 不新增 npm 运行时依赖; Windows 无 zstd 时跳过)。
|
|
268
|
+
*/
|
|
269
|
+
scanFile(file, onProgress) {
|
|
270
|
+
return new Promise((resolvePromise) => {
|
|
271
|
+
const sessionId = path.basename(path.dirname(file))
|
|
272
|
+
const cur = this._cursor(sessionId)
|
|
273
|
+
const startSeq = cur ? cur.lastSeq + 1 : 0
|
|
274
|
+
let lastSeq = cur ? cur.lastSeq : -1
|
|
275
|
+
let processed = 0
|
|
276
|
+
let currentModel = ''
|
|
277
|
+
let headerParsed = false
|
|
278
|
+
|
|
279
|
+
const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
280
|
+
const rl = readline.createInterface({ input: zstd.stdout })
|
|
281
|
+
|
|
282
|
+
zstd.on('error', (err) => {
|
|
283
|
+
if (err.code === 'ENOENT') {
|
|
284
|
+
console.warn(`[stats] 未找到 zstd 命令, 跳过历史回填: ${file}`)
|
|
285
|
+
} else {
|
|
286
|
+
console.warn(`[stats] zstd 解压失败 ${file}: ${err.message}`)
|
|
287
|
+
}
|
|
288
|
+
rl.close()
|
|
289
|
+
resolvePromise({ sessionId, processed, skipped: 0, error: err.code || err.message })
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
rl.on('line', (line) => {
|
|
293
|
+
let event
|
|
294
|
+
try {
|
|
295
|
+
event = JSON.parse(line)
|
|
296
|
+
} catch {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
// 首行是 session header, 没有 seq
|
|
300
|
+
if (!headerParsed) {
|
|
301
|
+
headerParsed = true
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (typeof event.seq !== 'number') return
|
|
305
|
+
if (event.seq <= lastSeq) return
|
|
306
|
+
if (event.seq > lastSeq + 1) {
|
|
307
|
+
// 日志理论上是连续 seq; 出现空洞时以文件为准继续顺序推进(seq 游标按文件顺序)
|
|
308
|
+
}
|
|
309
|
+
// 跟踪当前模型配置: request/header 与 request/context 都可能带模型
|
|
310
|
+
try {
|
|
311
|
+
if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
|
|
312
|
+
if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
|
|
313
|
+
} catch {}
|
|
314
|
+
if (event.type === 'assistant/message') {
|
|
315
|
+
const usage = normalizeUsage(event)
|
|
316
|
+
if (usage) {
|
|
317
|
+
const model = eventModel(event) || currentModel || 'unknown'
|
|
318
|
+
const { date, hour, period } = eventKey(event.time)
|
|
319
|
+
if (date >= PRICING_START_DATE) {
|
|
320
|
+
const day = this._loadDay(date)
|
|
321
|
+
const hourBucket = day.hours[hour] || (day.hours[hour] = {})
|
|
322
|
+
const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
|
|
323
|
+
addUsage(modelBucket, model, period, usage)
|
|
324
|
+
this._saveDay(day)
|
|
325
|
+
processed++
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lastSeq = event.seq
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
rl.on('close', () => {
|
|
333
|
+
if (lastSeq >= 0) {
|
|
334
|
+
this._setCursor(sessionId, lastSeq)
|
|
335
|
+
}
|
|
336
|
+
if (onProgress) onProgress({ sessionId, processed })
|
|
337
|
+
resolvePromise({ sessionId, processed })
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 扫描 ~/.dsh/sessions 下全部 session.jsonl.zstd。 */
|
|
343
|
+
async scanAll(sessionsRoot, onProgress) {
|
|
344
|
+
const root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
|
|
345
|
+
let files = []
|
|
346
|
+
try {
|
|
347
|
+
const walk = (dir) => {
|
|
348
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
349
|
+
if (ent.isDirectory()) walk(path.join(dir, ent.name))
|
|
350
|
+
else if (ent.name === 'session.jsonl.zstd') files.push(path.join(dir, ent.name))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
walk(root)
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.warn('[stats] 扫描会话目录失败: ' + (err.message || err))
|
|
356
|
+
return { files: 0, processed: 0 }
|
|
357
|
+
}
|
|
358
|
+
let processed = 0
|
|
359
|
+
// 串行扫描, 避免大量并发 zstd 子进程
|
|
360
|
+
for (const file of files) {
|
|
361
|
+
const out = await this.scanFile(file, onProgress)
|
|
362
|
+
processed += out.processed || 0
|
|
363
|
+
}
|
|
364
|
+
return { files: files.length, processed }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
summary(days) {
|
|
368
|
+
const n = Math.max(1, Math.min(Number(days) || 7, 90))
|
|
369
|
+
const out = []
|
|
370
|
+
// 查询窗口起点: 不早于定价生效日(生效日之前不统计)
|
|
371
|
+
const today = beijingDate(Date.now())
|
|
372
|
+
const startD = new Date(Date.now() + BJ_OFFSET_MS)
|
|
373
|
+
startD.setUTCDate(startD.getUTCDate() - (n - 1))
|
|
374
|
+
const windowStart = `${startD.getUTCFullYear()}-${pad2(startD.getUTCMonth() + 1)}-${pad2(startD.getUTCDate())}`
|
|
375
|
+
const first = windowStart > PRICING_START_DATE ? windowStart : PRICING_START_DATE
|
|
376
|
+
const [fy, fm, fd] = first.split('-').map(Number)
|
|
377
|
+
for (let cur = Date.UTC(fy, fm - 1, fd); ; cur += 86400_000) {
|
|
378
|
+
const date = beijingDate(cur)
|
|
379
|
+
if (date > today) break
|
|
380
|
+
const day = this._loadDay(date)
|
|
381
|
+
const s = dayTotals(day)
|
|
382
|
+
const byModel = {}
|
|
383
|
+
for (const hourStr of Object.keys(day.hours || {})) {
|
|
384
|
+
const period = periodOfHour(Number(hourStr))
|
|
385
|
+
for (const [model, bucket] of Object.entries(day.hours[hourStr])) {
|
|
386
|
+
const m = byModel[model] || (byModel[model] = { peak: emptyBucket(), off: emptyBucket(), total: emptyBucket() })
|
|
387
|
+
mergeBucket(m[period], bucket)
|
|
388
|
+
mergeBucket(m.total, bucket)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
out.push({ date, ...s, byModel })
|
|
392
|
+
}
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
detail(date) {
|
|
397
|
+
// 生效日前不返回统计(页面会展示空态与生效日提示)
|
|
398
|
+
const effective = date >= PRICING_START_DATE ? date : PRICING_START_DATE
|
|
399
|
+
const day = this._loadDay(effective)
|
|
400
|
+
const hours = []
|
|
401
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
402
|
+
const models = day.hours[hour] || {}
|
|
403
|
+
const total = emptyBucket()
|
|
404
|
+
for (const bucket of Object.values(models)) mergeBucket(total, bucket)
|
|
405
|
+
hours.push({ hour, period: periodOfHour(hour), models, total })
|
|
406
|
+
}
|
|
407
|
+
return { date: effective, pricingStart: PRICING_START_DATE, hours }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = {
|
|
412
|
+
BJ_OFFSET_MS,
|
|
413
|
+
PRICING_START_DATE,
|
|
414
|
+
PEAK_HOURS,
|
|
415
|
+
PRICES,
|
|
416
|
+
beijingHour,
|
|
417
|
+
beijingDate,
|
|
418
|
+
periodOfHour,
|
|
419
|
+
emptyBucket,
|
|
420
|
+
addUsage,
|
|
421
|
+
summarizeDay,
|
|
422
|
+
dayTotals,
|
|
423
|
+
eventKey,
|
|
424
|
+
eventModel,
|
|
425
|
+
normalizeUsage,
|
|
426
|
+
StatsStore,
|
|
427
|
+
}
|
package/gateway.cjs
CHANGED
|
@@ -30,6 +30,15 @@ const path = require('node:path')
|
|
|
30
30
|
const os = require('node:os')
|
|
31
31
|
const crypto = require('node:crypto')
|
|
32
32
|
|
|
33
|
+
let statsCore = null
|
|
34
|
+
let statsStore = null
|
|
35
|
+
try {
|
|
36
|
+
statsCore = require('./gateway-stats.cjs')
|
|
37
|
+
statsStore = new statsCore.StatsStore()
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn('[stats] 统计模块初始化失败, 统计 API 将不可用: ' + (err?.message || err))
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
const ROOT = __dirname
|
|
34
43
|
const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
35
44
|
const PORT = Number(process.env.PORT) || 8787
|
|
@@ -393,6 +402,97 @@ function cors(res) {
|
|
|
393
402
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
394
403
|
}
|
|
395
404
|
|
|
405
|
+
// ---------- 统计 API ----------
|
|
406
|
+
let statsScanning = false
|
|
407
|
+
async function scanStatsOnce(delay) {
|
|
408
|
+
if (statsScanning || !statsStore) return
|
|
409
|
+
statsScanning = true
|
|
410
|
+
if (delay) await new Promise(r => setTimeout(r, delay))
|
|
411
|
+
try {
|
|
412
|
+
const out = await statsStore.scanAll()
|
|
413
|
+
if (out.files) console.log(`[stats] 历史回填扫描完成: ${out.processed} 个新事件 (${out.files} 个会话文件)`)
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.warn('[stats] 历史回填扫描失败: ' + (err?.message || err))
|
|
416
|
+
} finally {
|
|
417
|
+
statsScanning = false
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function serveStats(req, res, url) {
|
|
422
|
+
cors(res)
|
|
423
|
+
if (req.method === 'OPTIONS') {
|
|
424
|
+
res.writeHead(204)
|
|
425
|
+
res.end()
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (!statsStore) {
|
|
429
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
|
|
430
|
+
res.end(JSON.stringify({ error: 'stats unavailable' }))
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (!authorized(req, url)) {
|
|
434
|
+
authFailures++
|
|
435
|
+
touchDevice(req, { failedAuth: true })
|
|
436
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
437
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
touchDevice(req)
|
|
441
|
+
const pathname = url.pathname
|
|
442
|
+
|
|
443
|
+
if (pathname === '/stats/summary' && req.method === 'GET') {
|
|
444
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
445
|
+
res.end(JSON.stringify({ ok: true, days: statsStore.summary(url.searchParams.get('days')) }))
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (pathname === '/stats/detail' && req.method === 'GET') {
|
|
450
|
+
const date = url.searchParams.get('date') || statsCore.beijingDate(Date.now())
|
|
451
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
452
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
453
|
+
res.end(JSON.stringify({ error: 'invalid date', expect: 'YYYY-MM-DD' }))
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
457
|
+
res.end(JSON.stringify({ ok: true, ...statsStore.detail(date) }))
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (pathname === '/stats/ingest' && req.method === 'POST') {
|
|
462
|
+
let body = ''
|
|
463
|
+
req.on('data', c => { body += c; if (body.length > 256 * 1024) req.destroy() })
|
|
464
|
+
req.on('end', () => {
|
|
465
|
+
let payload
|
|
466
|
+
try {
|
|
467
|
+
payload = JSON.parse(body || '{}')
|
|
468
|
+
} catch {
|
|
469
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
470
|
+
res.end(JSON.stringify({ error: 'invalid json' }))
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const sessionId = payload.sessionId
|
|
474
|
+
const event = payload.event
|
|
475
|
+
if (!sessionId || !event || typeof event !== 'object') {
|
|
476
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
477
|
+
res.end(JSON.stringify({ error: 'sessionId 与 event 必填' }))
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
statsStore.ingestEvent(sessionId, event, payload.fallbackModel).then((out) => {
|
|
481
|
+
if (out.gap) scanStatsOnce(3000)
|
|
482
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
483
|
+
res.end(JSON.stringify({ ok: true, ...out }))
|
|
484
|
+
}).catch((err) => {
|
|
485
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
486
|
+
res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
|
|
493
|
+
res.end(JSON.stringify({ error: 'not found' }))
|
|
494
|
+
}
|
|
495
|
+
|
|
396
496
|
// ---------- 静态文件 ----------
|
|
397
497
|
function serveStatic(req, res, url) {
|
|
398
498
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
@@ -1328,6 +1428,7 @@ const server = http.createServer((req, res) => {
|
|
|
1328
1428
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
1329
1429
|
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
|
|
1330
1430
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
1431
|
+
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
1331
1432
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
1332
1433
|
if (url.pathname === '/health') return serveHealth(res)
|
|
1333
1434
|
touchDevice(req)
|
|
@@ -1452,4 +1553,7 @@ server.listen(PORT, HOST, () => {
|
|
|
1452
1553
|
// 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
|
|
1453
1554
|
setTimeout(() => checkForUpdates(false), 8000)
|
|
1454
1555
|
setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
|
|
1556
|
+
// 统计回填: 启动 2 秒后全量扫描一次, 之后每 5 分钟增量扫描(seq 游标保证幂等)
|
|
1557
|
+
scanStatsOnce(2000)
|
|
1558
|
+
setInterval(() => scanStatsOnce(0), 5 * 60 * 1000)
|
|
1455
1559
|
})
|
package/index.mjs
CHANGED
|
@@ -238,6 +238,52 @@ async function stopGateway() {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// ---------- 统计事件投递(实时 assistant/message + usage -> 网关 /stats/ingest) ----------
|
|
242
|
+
// 网关未运行时静默失败: 网关启动后会按 seq 游标扫描 session.jsonl.zstd 补齐。
|
|
243
|
+
const statsQueues = new Map() // sessionId -> Promise 串行队列(保证 seq 顺序)
|
|
244
|
+
|
|
245
|
+
function statsSend(session, event) {
|
|
246
|
+
if (event.type !== 'assistant/message') return
|
|
247
|
+
const usage = event.data?.usage
|
|
248
|
+
if (!usage || typeof usage !== 'object') return
|
|
249
|
+
const token = gatewayToken()
|
|
250
|
+
if (!token) return
|
|
251
|
+
let fallbackModel = ''
|
|
252
|
+
try { fallbackModel = session.requestContext?.()?.model || '' } catch {}
|
|
253
|
+
const payload = {
|
|
254
|
+
sessionId: session.id,
|
|
255
|
+
fallbackModel,
|
|
256
|
+
event: {
|
|
257
|
+
type: 'assistant/message',
|
|
258
|
+
seq: event.seq,
|
|
259
|
+
time: event.time,
|
|
260
|
+
data: {
|
|
261
|
+
usage: {
|
|
262
|
+
inputTokens: usage.inputTokens,
|
|
263
|
+
outputTokens: usage.outputTokens,
|
|
264
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
265
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
266
|
+
},
|
|
267
|
+
message: { source: { model: event.data?.message?.source?.model || '' } },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
const prev = statsQueues.get(session.id) || Promise.resolve()
|
|
272
|
+
const next = prev.then(async () => {
|
|
273
|
+
try {
|
|
274
|
+
await fetch(`${GATEWAY_BASE}/stats/ingest`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
277
|
+
body: JSON.stringify(payload),
|
|
278
|
+
signal: AbortSignal.timeout(2000),
|
|
279
|
+
})
|
|
280
|
+
} catch {}
|
|
281
|
+
}).finally(() => {
|
|
282
|
+
if (statsQueues.get(session.id) === next) statsQueues.delete(session.id)
|
|
283
|
+
})
|
|
284
|
+
statsQueues.set(session.id, next)
|
|
285
|
+
}
|
|
286
|
+
|
|
241
287
|
async function resolveFile(pathname) {
|
|
242
288
|
let abs = targetPath(pathname)
|
|
243
289
|
if (abs === null) return null
|
|
@@ -284,6 +330,19 @@ async function serveStatic(req, res) {
|
|
|
284
330
|
return
|
|
285
331
|
}
|
|
286
332
|
|
|
333
|
+
// 统计面板数据: 代理到本地网关 /stats/*(网关未运行则返回 502)
|
|
334
|
+
if (pathname === `${MOUNT}/admin/api/stats/summary` || pathname === `${MOUNT}/admin/api/stats/detail`) {
|
|
335
|
+
const query = new URL(req.url ?? '/', 'http://x').search
|
|
336
|
+
const sub = pathname.slice(`${MOUNT}/admin/api/stats`.length)
|
|
337
|
+
const proxied = await proxyGateway(`/stats${sub}${query}`, 'GET', '')
|
|
338
|
+
if (proxied !== null) {
|
|
339
|
+
sendJson(res, proxied.status, proxied.json)
|
|
340
|
+
} else {
|
|
341
|
+
sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要 8787 网关运行' })
|
|
342
|
+
}
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
287
346
|
// 管理控制台数据: 优先代理本地网关(设备监控/更新检查完整), 网关不可用回退插件状态
|
|
288
347
|
if (pathname === `${MOUNT}/admin/api/state`) {
|
|
289
348
|
void ensureGateway() // 自愈: 开关为 on 而网关没起来时, 后台拉起, 下个轮询即可见网关
|
|
@@ -389,6 +448,10 @@ export function apply(ctx) {
|
|
|
389
448
|
path: MOUNT,
|
|
390
449
|
handler: serveStatic,
|
|
391
450
|
}), 'dsh-remote: /remote route')
|
|
451
|
+
// 实时统计: 监听 DSH 会话事件流, 把带 usage 的 assistant/message 投递到网关聚合
|
|
452
|
+
ctx.on('session/event', (session, event) => {
|
|
453
|
+
statsSend(session, event)
|
|
454
|
+
})
|
|
392
455
|
// DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
|
|
393
456
|
void ensureGateway()
|
|
394
457
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"index.mjs",
|
|
15
15
|
"client.js",
|
|
16
16
|
"gateway.cjs",
|
|
17
|
+
"gateway-stats.cjs",
|
|
17
18
|
"public",
|
|
18
19
|
"apk",
|
|
19
20
|
"cordis.patch.yml",
|
package/public/admin.html
CHANGED
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
.stat-card .v { font-size: 20px; font-weight: 700; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
19
19
|
.stat-card .k { font-size: 11px; color: var(--dsr-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
20
20
|
.stat-card.ok .v { color: var(--dsr-success); } .stat-card.warn .v { color: var(--dsr-warning); }
|
|
21
|
+
.stats-dash { margin-bottom: 14px; }
|
|
22
|
+
.stats-dash .stat-grid { margin-bottom: 8px; }
|
|
23
|
+
.stats-chart { display: flex; align-items: flex-end; gap: 8px; height: 180px; padding: 12px 14px 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
|
|
24
|
+
.stats-legend { display: flex; gap: 12px; font-size: 11px; color: var(--dsr-muted); margin: 6px 2px 0; }
|
|
25
|
+
.stats-legend .lg { display: inline-flex; align-items: center; gap: 4px; }
|
|
26
|
+
.stats-legend .sw { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
|
|
27
|
+
.stats-legend .sw.peak { background: var(--dsr-accent-strong); }
|
|
28
|
+
.stats-legend .sw.off { background: var(--dsr-accent-2); }
|
|
29
|
+
.bucket-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px; margin-top: 6px; }
|
|
30
|
+
.bucket-grid .b { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 11px; min-width: 0; }
|
|
31
|
+
.bucket-grid .b .n { color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
32
|
+
.bucket-grid .b .t { font-weight: 600; white-space: nowrap; }
|
|
33
|
+
.stats-bar { flex: 1; min-width: 38px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 5px; }
|
|
34
|
+
.stats-bar .bars { width: 100%; max-width: 46px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 6px 6px 0 0; overflow: hidden; background: var(--dsr-accent-soft); }
|
|
35
|
+
.stats-bar .seg { width: 100%; }
|
|
36
|
+
.stats-bar .seg.peak { background: var(--dsr-accent-strong); }
|
|
37
|
+
.stats-bar .seg.off { background: var(--dsr-accent-2); }
|
|
38
|
+
.stats-bar .lbl { font-size: 10px; color: var(--dsr-muted); white-space: nowrap; }
|
|
39
|
+
.stats-bar .val { font-size: 10px; color: var(--dsr-text); white-space: nowrap; }
|
|
40
|
+
.stats-empty { color: var(--dsr-muted); text-align: center; padding: 20px 0; font-size: 12px; }
|
|
41
|
+
.stats-note { color: var(--dsr-muted); font-size: 11px; line-height: 1.6; margin-top: 8px; }
|
|
21
42
|
.table-wrap { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; }
|
|
22
43
|
table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 0; }
|
|
23
44
|
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--dsr-line); vertical-align: top; }
|
|
@@ -144,6 +165,14 @@
|
|
|
144
165
|
|
|
145
166
|
<div class="stat-grid" id="stats"></div>
|
|
146
167
|
|
|
168
|
+
<div class="stats-dash">
|
|
169
|
+
<div class="section-head"><span data-i18n="stats.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
|
|
170
|
+
<div class="stat-grid" id="stats-cards"></div>
|
|
171
|
+
<div id="stats-legend" class="stats-legend"></div>
|
|
172
|
+
<div id="stats-chart" class="stats-chart"></div>
|
|
173
|
+
<div id="stats-note" class="stats-note"></div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
147
176
|
<div class="section-head"><span data-i18n="devices">已连接设备</span><span id="device-summary" class="muted"></span></div>
|
|
148
177
|
<div class="table-wrap">
|
|
149
178
|
<table>
|
|
@@ -209,6 +238,18 @@
|
|
|
209
238
|
'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
|
|
210
239
|
'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
|
|
211
240
|
'stat.uptime': '运行时长 · {host}:{port}',
|
|
241
|
+
'stats.title': 'Token 统计',
|
|
242
|
+
'stats.todayTokens': '今日 Token',
|
|
243
|
+
'stats.todayCost': '今日费用',
|
|
244
|
+
'stats.peakShare': '高峰占比',
|
|
245
|
+
'stats.input': '未缓存输入',
|
|
246
|
+
'stats.cacheRead': '缓存命中',
|
|
247
|
+
'stats.cacheWrite': '缓存写入',
|
|
248
|
+
'stats.output': '输出',
|
|
249
|
+
'stats.peak': '高峰', 'stats.off': '空闲',
|
|
250
|
+
'stats.days': '近 {n} 天',
|
|
251
|
+
'stats.gatewayDown': '统计需要 8787 网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
|
|
252
|
+
'stats.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
|
|
212
253
|
'unit.sec': ' 秒', 'unit.min': ' 分钟', 'unit.hour': ' 小时 ', 'unit.minShort': ' 分', 'unit.day': ' 天 ',
|
|
213
254
|
'device.installedNotRunning': '网关已安装 · 当前未运行', 'device.noGatewayBinary': '未检测到网关程序',
|
|
214
255
|
'device.ipRefresh': '{n} 个 IP · 每 5 秒刷新',
|
|
@@ -269,6 +310,18 @@
|
|
|
269
310
|
'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
|
|
270
311
|
'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
|
|
271
312
|
'stat.uptime': 'Uptime · {host}:{port}',
|
|
313
|
+
'stats.title': 'Token stats',
|
|
314
|
+
'stats.todayTokens': 'Tokens today',
|
|
315
|
+
'stats.todayCost': 'Cost today',
|
|
316
|
+
'stats.peakShare': 'Peak share',
|
|
317
|
+
'stats.input': 'Uncached input',
|
|
318
|
+
'stats.cacheRead': 'Cache read',
|
|
319
|
+
'stats.cacheWrite': 'Cache write',
|
|
320
|
+
'stats.output': 'Output',
|
|
321
|
+
'stats.peak': 'Peak', 'stats.off': 'Off-peak',
|
|
322
|
+
'stats.days': 'Last {n} days',
|
|
323
|
+
'stats.gatewayDown': 'Stats require the gateway on 8787', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
|
|
324
|
+
'stats.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
|
|
272
325
|
'unit.sec': 's', 'unit.min': 'min', 'unit.hour': 'h ', 'unit.minShort': 'm', 'unit.day': 'd ',
|
|
273
326
|
'device.installedNotRunning': 'Gateway installed · not running', 'device.noGatewayBinary': 'Gateway binary not found',
|
|
274
327
|
'device.ipRefresh': '{n} IPs · refreshed every 5s',
|
package/public/admin.js
CHANGED
|
@@ -25,6 +25,93 @@ let shownToken = token
|
|
|
25
25
|
let lastState = null
|
|
26
26
|
let qrShown = false
|
|
27
27
|
|
|
28
|
+
const STATS_API = pluginMode ? API + '/stats' : '/stats'
|
|
29
|
+
let statsTimer = null
|
|
30
|
+
|
|
31
|
+
function fmtTokens(n) {
|
|
32
|
+
n = Number(n) || 0
|
|
33
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M'
|
|
34
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + 'K'
|
|
35
|
+
return String(Math.round(n))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fmtCost(n) {
|
|
39
|
+
return '¥' + (Number(n) || 0).toFixed(2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bucketTokens(b) {
|
|
43
|
+
return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadStats() {
|
|
47
|
+
if (!token && !pluginMode) return
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${STATS_API}/summary?days=7`, {
|
|
50
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
|
|
51
|
+
})
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
if (res.status === 401) return
|
|
54
|
+
throw new Error('HTTP ' + res.status)
|
|
55
|
+
}
|
|
56
|
+
const json = await res.json()
|
|
57
|
+
renderStats(json.days || [])
|
|
58
|
+
} catch (e) {
|
|
59
|
+
$('stats-cards').innerHTML = ''
|
|
60
|
+
$('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.gatewayDown')}</div>`
|
|
61
|
+
$('stats-sub').textContent = ''
|
|
62
|
+
$('stats-note').textContent = ''
|
|
63
|
+
$('stats-legend').innerHTML = ''
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderStats(days) {
|
|
68
|
+
if (!days.length) {
|
|
69
|
+
$('stats-cards').innerHTML = ''
|
|
70
|
+
$('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.empty')}</div>`
|
|
71
|
+
$('stats-sub').textContent = ''
|
|
72
|
+
$('stats-note').textContent = t('stats.note')
|
|
73
|
+
$('stats-legend').innerHTML = ''
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const today = days[days.length - 1]
|
|
77
|
+
const totalTokens = bucketTokens(today.total)
|
|
78
|
+
const peakCost = today.peak.cost || 0
|
|
79
|
+
const offCost = today.off.cost || 0
|
|
80
|
+
const totalCost = peakCost + offCost
|
|
81
|
+
const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
|
|
82
|
+
$('stats-cards').innerHTML = `
|
|
83
|
+
<div class="stat-card"><div class="v">${fmtTokens(totalTokens)} <span style="font-size:12px;font-weight:500;color:var(--dsr-muted)">${t('stats.todayTokens')}</span></div>
|
|
84
|
+
<div class="bucket-grid">
|
|
85
|
+
<div class="b"><span class="n">${t('stats.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
|
|
86
|
+
<div class="b"><span class="n">${t('stats.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
|
|
87
|
+
<div class="b"><span class="n">${t('stats.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
|
|
88
|
+
<div class="b"><span class="n">${t('stats.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
|
|
89
|
+
</div></div>
|
|
90
|
+
<div class="stat-card"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('stats.todayCost')} · ${t('stats.peak')} ${fmtCost(peakCost)} / ${t('stats.off')} ${fmtCost(offCost)}</div></div>
|
|
91
|
+
<div class="stat-card ${peakShare >= 50 ? 'warn' : 'ok'}"><div class="v">${peakShare}%</div><div class="k">${t('stats.peakShare')} · ${t('stats.days', { n: days.length })}</div></div>`
|
|
92
|
+
$('stats-sub').textContent = today.date
|
|
93
|
+
$('stats-note').textContent = t('stats.note')
|
|
94
|
+
$('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('stats.peak')}</span><span class="lg"><span class="sw off"></span>${t('stats.off')}</span>`
|
|
95
|
+
|
|
96
|
+
// 近 7 日柱状图: 柱总高按当日费用相对窗口最大值, 柱内峰/谷按当日实际占比堆叠
|
|
97
|
+
const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
|
|
98
|
+
$('stats-chart').innerHTML = days.map(d => {
|
|
99
|
+
const cost = d.total.cost || 0
|
|
100
|
+
const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
|
|
101
|
+
const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
|
|
102
|
+
const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
|
|
103
|
+
const label = d.date.slice(5)
|
|
104
|
+
return `<div class="stats-bar" title="${d.date} · ${t('stats.peak')} ${fmtCost(d.peak.cost)} · ${t('stats.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
|
|
105
|
+
<div class="bars" style="height:${totalH}%">
|
|
106
|
+
<div class="seg peak" style="height:${peakH}%"></div>
|
|
107
|
+
<div class="seg off" style="height:${offH}%"></div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
|
|
110
|
+
<div class="lbl">${label}</div>
|
|
111
|
+
</div>`
|
|
112
|
+
}).join('')
|
|
113
|
+
}
|
|
114
|
+
|
|
28
115
|
function toast(text, kind = '') {
|
|
29
116
|
const el = $('toast')
|
|
30
117
|
el.textContent = text
|
|
@@ -227,7 +314,10 @@ function enter() {
|
|
|
227
314
|
history.replaceState(null, '', location.pathname)
|
|
228
315
|
showMain()
|
|
229
316
|
loadState()
|
|
317
|
+
loadStats()
|
|
230
318
|
timer = setInterval(loadState, 5000)
|
|
319
|
+
if (statsTimer) clearInterval(statsTimer)
|
|
320
|
+
statsTimer = setInterval(loadStats, 30000)
|
|
231
321
|
}
|
|
232
322
|
|
|
233
323
|
function showMain() {
|
|
@@ -239,6 +329,8 @@ function logout() {
|
|
|
239
329
|
token = ''
|
|
240
330
|
store.del('dshAdminToken')
|
|
241
331
|
clearInterval(timer)
|
|
332
|
+
if (statsTimer) clearInterval(statsTimer)
|
|
333
|
+
statsTimer = null
|
|
242
334
|
$('main-view').classList.add('hidden')
|
|
243
335
|
$('login-view').classList.remove('hidden')
|
|
244
336
|
$('conn-badge').textContent = t('unauth')
|
|
@@ -394,7 +486,10 @@ function start(showLogin) {
|
|
|
394
486
|
}
|
|
395
487
|
showMain()
|
|
396
488
|
loadState()
|
|
489
|
+
loadStats()
|
|
397
490
|
timer = setInterval(loadState, 5000)
|
|
491
|
+
if (statsTimer) clearInterval(statsTimer)
|
|
492
|
+
statsTimer = setInterval(loadStats, 30000)
|
|
398
493
|
}
|
|
399
494
|
|
|
400
495
|
if (pluginMode) {
|
package/public/app.js
CHANGED
|
@@ -108,6 +108,92 @@ function fmtFullTime(ts) {
|
|
|
108
108
|
function apiUrl(path) {
|
|
109
109
|
return (state.server || '') + path
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
function fmtCost(n) {
|
|
113
|
+
return '¥' + (Number(n) || 0).toFixed(2)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function bucketTokens(b) {
|
|
117
|
+
return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------------- Token 统计页 ---------------- */
|
|
121
|
+
async function loadStats() {
|
|
122
|
+
const cards = $('stats-cards')
|
|
123
|
+
const chart = $('stats-chart')
|
|
124
|
+
const note = $('stats-note')
|
|
125
|
+
if (!state.token) {
|
|
126
|
+
cards.innerHTML = ''
|
|
127
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
128
|
+
note.textContent = ''
|
|
129
|
+
$('stats-legend').innerHTML = ''
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(apiUrl('/stats/summary?days=7'), {
|
|
134
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
135
|
+
})
|
|
136
|
+
if (res.status === 401) { authFailure(); return }
|
|
137
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
138
|
+
const json = await res.json()
|
|
139
|
+
renderStats(json.days || [])
|
|
140
|
+
} catch (e) {
|
|
141
|
+
cards.innerHTML = ''
|
|
142
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
143
|
+
note.textContent = ''
|
|
144
|
+
$('stats-legend').innerHTML = ''
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderStats(days) {
|
|
149
|
+
const cards = $('stats-cards')
|
|
150
|
+
const chart = $('stats-chart')
|
|
151
|
+
const note = $('stats-note')
|
|
152
|
+
if (!days.length) {
|
|
153
|
+
cards.innerHTML = ''
|
|
154
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
|
|
155
|
+
note.textContent = t('statsPage.note')
|
|
156
|
+
$('stats-sub').textContent = ''
|
|
157
|
+
$('stats-legend').innerHTML = ''
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const today = days[days.length - 1]
|
|
161
|
+
const totalTokens = bucketTokens(today.total)
|
|
162
|
+
const peakCost = today.peak.cost || 0
|
|
163
|
+
const offCost = today.off.cost || 0
|
|
164
|
+
const totalCost = peakCost + offCost
|
|
165
|
+
const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
|
|
166
|
+
cards.innerHTML = `
|
|
167
|
+
<div class="scard span2"><div class="v">${fmtTokens(totalTokens)} <span style="font-size:11px;font-weight:500;color:var(--dsr-muted)">${t('statsPage.todayTokens')}</span></div>
|
|
168
|
+
<div class="bucket-grid">
|
|
169
|
+
<div class="b"><span class="n">${t('statsPage.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
|
|
170
|
+
<div class="b"><span class="n">${t('statsPage.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
|
|
171
|
+
<div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
|
|
172
|
+
<div class="b"><span class="n">${t('statsPage.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
|
|
173
|
+
</div></div>
|
|
174
|
+
<div class="scard"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('statsPage.todayCost')}<br>${t('statsPage.peak')} ${fmtCost(peakCost)}<br>${t('statsPage.off')} ${fmtCost(offCost)}</div></div>
|
|
175
|
+
<div class="scard"><div class="v">${peakShare}%</div><div class="k">${t('statsPage.peakShare')}<br>${t('statsPage.days', { n: days.length })}</div></div>`
|
|
176
|
+
$('stats-sub').textContent = today.date
|
|
177
|
+
note.textContent = t('statsPage.note')
|
|
178
|
+
$('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('statsPage.peak')}</span><span class="lg"><span class="sw off"></span>${t('statsPage.off')}</span>`
|
|
179
|
+
const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
|
|
180
|
+
chart.innerHTML = days.map(d => {
|
|
181
|
+
const cost = d.total.cost || 0
|
|
182
|
+
// 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
|
|
183
|
+
const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
|
|
184
|
+
const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
|
|
185
|
+
const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
|
|
186
|
+
const label = d.date.slice(5)
|
|
187
|
+
return `<div class="stats-bar" title="${d.date} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
|
|
188
|
+
<div class="bars" style="height:${totalH}%">
|
|
189
|
+
<div class="seg peak" style="height:${peakH}%"></div>
|
|
190
|
+
<div class="seg off" style="height:${offH}%"></div>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
|
|
193
|
+
<div class="lbl">${label}</div>
|
|
194
|
+
</div>`
|
|
195
|
+
}).join('')
|
|
196
|
+
}
|
|
111
197
|
async function rpc(method, payload = {}) {
|
|
112
198
|
const res = await fetch(apiUrl('/api/' + method), {
|
|
113
199
|
method: 'POST',
|
|
@@ -1851,12 +1937,13 @@ function notify(title, body) {
|
|
|
1851
1937
|
|
|
1852
1938
|
/* ---------------- 视图切换 ---------------- */
|
|
1853
1939
|
function showView(id) {
|
|
1854
|
-
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1940
|
+
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1855
1941
|
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
1856
1942
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
1857
1943
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
1858
1944
|
window.scrollTo(0, 0)
|
|
1859
1945
|
if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
|
|
1946
|
+
if (id === 'view-stats') loadStats()
|
|
1860
1947
|
}
|
|
1861
1948
|
|
|
1862
1949
|
function updateConn() {
|
package/public/index.html
CHANGED
|
@@ -128,6 +128,15 @@
|
|
|
128
128
|
<div id="jobs-list" class="jobs-list"></div>
|
|
129
129
|
</section>
|
|
130
130
|
|
|
131
|
+
<!-- Token 统计 -->
|
|
132
|
+
<section id="view-stats" class="view hidden">
|
|
133
|
+
<div class="section-head"><span data-i18n="statsPage.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
|
|
134
|
+
<div class="stats-cards" id="stats-cards"></div>
|
|
135
|
+
<div id="stats-legend" class="stats-legend"></div>
|
|
136
|
+
<div id="stats-chart" class="stats-chart"></div>
|
|
137
|
+
<div id="stats-note" class="stats-note"></div>
|
|
138
|
+
</section>
|
|
139
|
+
|
|
131
140
|
<!-- 设置 -->
|
|
132
141
|
<section id="view-settings" class="view hidden">
|
|
133
142
|
<div class="settings-group">
|
|
@@ -189,6 +198,7 @@
|
|
|
189
198
|
<button data-view="view-home" class="nav-btn active"><span class="nav-ico">▤</span><span data-i18n="nav.sessions">会话</span></button>
|
|
190
199
|
<button data-view="view-files" class="nav-btn"><span class="nav-ico">⇅</span><span data-i18n="nav.files">文件</span></button>
|
|
191
200
|
<button data-view="view-activity" class="nav-btn"><span class="nav-ico">◷</span><span data-i18n="nav.pending">待办</span><b id="nav-pending" class="nav-badge hidden"></b></button>
|
|
201
|
+
<button data-view="view-stats" class="nav-btn"><span class="nav-ico">▦</span><span data-i18n="nav.stats">统计</span></button>
|
|
192
202
|
<button data-view="view-settings" class="nav-btn"><span class="nav-ico">⚙</span><span data-i18n="nav.settings">设置</span></button>
|
|
193
203
|
</nav>
|
|
194
204
|
|
|
@@ -258,7 +268,13 @@
|
|
|
258
268
|
'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
|
|
259
269
|
'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
|
|
260
270
|
'common.refreshing': '刷新中…',
|
|
261
|
-
'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.settings': '设置',
|
|
271
|
+
'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.stats': '统计', 'nav.settings': '设置',
|
|
272
|
+
'statsPage.title': 'Token 统计',
|
|
273
|
+
'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比',
|
|
274
|
+
'statsPage.input': '未缓存输入', 'statsPage.cacheRead': '缓存命中', 'statsPage.cacheWrite': '缓存写入', 'statsPage.output': '输出',
|
|
275
|
+
'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
|
|
276
|
+
'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
|
|
277
|
+
'statsPage.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
|
|
262
278
|
'home.newSession': '+ 新会话', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
|
|
263
279
|
'sessions.running': '运行中', 'sessions.statRunning': '运行中', 'sessions.statPending': '待处理', 'sessions.statTotal': '会话总数',
|
|
264
280
|
'sessions.queueBadge': '队列 {n}', 'sessions.goalBadge': '目标·{phase}',
|
|
@@ -375,7 +391,13 @@
|
|
|
375
391
|
'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
|
|
376
392
|
'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
|
|
377
393
|
'common.refreshing': 'Refreshing…',
|
|
378
|
-
'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.settings': 'Settings',
|
|
394
|
+
'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
|
|
395
|
+
'statsPage.title': 'Token stats',
|
|
396
|
+
'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share',
|
|
397
|
+
'statsPage.input': 'Uncached input', 'statsPage.cacheRead': 'Cache read', 'statsPage.cacheWrite': 'Cache write', 'statsPage.output': 'Output',
|
|
398
|
+
'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
|
|
399
|
+
'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
|
|
400
|
+
'statsPage.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
|
|
379
401
|
'home.newSession': '+ New session', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
|
|
380
402
|
'sessions.running': 'Running', 'sessions.statRunning': 'Running', 'sessions.statPending': 'Pending', 'sessions.statTotal': 'Sessions',
|
|
381
403
|
'sessions.queueBadge': 'Queue {n}', 'sessions.goalBadge': 'Goal·{phase}',
|
package/public/styles.css
CHANGED
|
@@ -450,6 +450,32 @@ button {
|
|
|
450
450
|
}
|
|
451
451
|
.section-head.small { margin-top: 6px; }
|
|
452
452
|
|
|
453
|
+
/* ---------- Token 统计(移动端) ---------- */
|
|
454
|
+
.stats-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
455
|
+
.scard { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 10px 11px; min-width: 0; }
|
|
456
|
+
.scard.span2 { grid-column: span 2; }
|
|
457
|
+
.scard .v { font-size: 17px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
458
|
+
.scard .k { font-size: 10px; color: var(--dsr-muted); margin-top: 2px; line-height: 1.4; }
|
|
459
|
+
.bucket-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 10px; margin-top: 6px; }
|
|
460
|
+
.bucket-grid .b { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; font-size: 10.5px; min-width: 0; }
|
|
461
|
+
.bucket-grid .b .n { color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
462
|
+
.bucket-grid .b .t { font-weight: 600; white-space: nowrap; }
|
|
463
|
+
.stats-chart { display: flex; align-items: flex-end; gap: 7px; height: 190px; padding: 12px 12px 10px; margin-top: 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
|
|
464
|
+
.stats-legend { display: flex; gap: 12px; font-size: 10px; color: var(--dsr-muted); margin-top: 6px; }
|
|
465
|
+
.stats-legend .lg { display: inline-flex; align-items: center; gap: 4px; }
|
|
466
|
+
.stats-legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
|
467
|
+
.stats-legend .sw.peak { background: var(--dsr-accent-strong); }
|
|
468
|
+
.stats-legend .sw.off { background: var(--dsr-accent-2); }
|
|
469
|
+
.stats-bar { flex: 1; min-width: 36px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px; }
|
|
470
|
+
.stats-bar .bars { width: 100%; max-width: 42px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 6px 6px 0 0; overflow: hidden; background: var(--dsr-accent-soft); }
|
|
471
|
+
.stats-bar .seg { width: 100%; }
|
|
472
|
+
.stats-bar .seg.peak { background: var(--dsr-accent-strong); }
|
|
473
|
+
.stats-bar .seg.off { background: var(--dsr-accent-2); }
|
|
474
|
+
.stats-bar .lbl { font-size: 9px; color: var(--dsr-muted); white-space: nowrap; }
|
|
475
|
+
.stats-bar .val { font-size: 9px; color: var(--dsr-text); white-space: nowrap; }
|
|
476
|
+
.stats-note { font-size: 11px; color: var(--dsr-muted); line-height: 1.6; margin-top: 8px; }
|
|
477
|
+
.stats-empty { color: var(--dsr-muted); text-align: center; padding: 18px 0; font-size: 12px; }
|
|
478
|
+
|
|
453
479
|
/* ---------- 会话列表 ---------- */
|
|
454
480
|
.session-list { display: flex; flex-direction: column; gap: 8px; }
|
|
455
481
|
.session-card {
|
package/public/update.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.5.
|
|
2
|
+
"version": "0.5.7",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"releasedAt": "2026-08-
|
|
5
|
-
"notes": "
|
|
4
|
+
"releasedAt": "2026-08-17T06:01:38.138Z",
|
|
5
|
+
"notes": "新增:Token 统计 v1(管理页 + App 统计页):今日四桶/费用/高峰占比与近 7 日柱状图,按北京时间高峰计费,统计自 2026-08-17 定价生效日起;数据基于 token 估算,以官网账单为准。"
|
|
6
6
|
}
|
package/public/version.json
CHANGED