@dijkspicy/opencode-stats-reporter 0.1.0
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/LICENSE +198 -0
- package/README.md +135 -0
- package/bin/opencode-stats-reporter.js +7 -0
- package/contrib/opencode-session-reporter/README.md +92 -0
- package/contrib/opencode-session-reporter/package.json +16 -0
- package/contrib/opencode-session-reporter/plugin-server.ts +14 -0
- package/contrib/opencode-session-reporter/reporter.test.ts +378 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +610 -0
- package/package.json +30 -0
- package/panel/config.example.js +35 -0
- package/panel/css/style.css +615 -0
- package/panel/index.html +398 -0
- package/panel/js/api.js +154 -0
- package/panel/js/app.js +1167 -0
- package/panel/js/charts.js +241 -0
- package/panel/js/config.js +125 -0
- package/panel/js/format.js +156 -0
- package/panel/js/sessions.js +412 -0
- package/src/cli.js +164 -0
- package/src/config.js +57 -0
- package/src/identity.js +34 -0
- package/src/paths.js +37 -0
- package/src/proxy.js +31 -0
- package/src/server.js +164 -0
- package/src/session-api.js +97 -0
- package/src/store.js +246 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话视图:客户端上报的会话级 token 用量
|
|
3
|
+
* 数据源:/v1/sessions*(客户端上报,可能延迟/遗漏,非网关权威数据)
|
|
4
|
+
* token 口径(已实测验证):输入总量 = 输入 + 缓存读 + 缓存写;输出总量 = 输出 + 推理(推理单列,不并入输出)
|
|
5
|
+
* 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 会话列表(窗口/模型/代理筛选 + 游标分页)→ 会话详情
|
|
6
|
+
* 由 app.js 初始化:BRU.sessions.init({ getCfg, getPeriod, getModelCatalog, onUnauthorized }),并经 BRU.sessions.load() 触发拉取
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
var F = window.BRU.format;
|
|
12
|
+
var API = window.BRU.api;
|
|
13
|
+
|
|
14
|
+
var PAGE_SIZE = 50;
|
|
15
|
+
var UNAUTH_MSG = 'API Key 无效或已过期,请重新输入';
|
|
16
|
+
|
|
17
|
+
var deps = null;
|
|
18
|
+
var el = {};
|
|
19
|
+
|
|
20
|
+
var st = {
|
|
21
|
+
seq: 0, // 请求代次:响应到达时核对,过期响应(切窗/切筛选后)直接丢弃
|
|
22
|
+
period: 'today',
|
|
23
|
+
rows: [], // 已加载会话(分页累积)
|
|
24
|
+
nextCursor: null, // 下一页游标(null = 没有更多)
|
|
25
|
+
filters: { model: '', agent: '' },
|
|
26
|
+
modelSet: {}, // 模型筛选项(模型目录 + 已见会话模型)
|
|
27
|
+
agentSet: {}, // 代理筛选项(已见会话代理)
|
|
28
|
+
selectedId: null // 当前打开详情的会话 ID
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------- DOM 工具 ----------
|
|
32
|
+
function $(id) { return document.getElementById(id); }
|
|
33
|
+
function show(e) { e.classList.remove('hidden'); }
|
|
34
|
+
function hide(e) { e.classList.add('hidden'); }
|
|
35
|
+
|
|
36
|
+
function td(text, cls, title) {
|
|
37
|
+
var c = document.createElement('td');
|
|
38
|
+
c.textContent = text;
|
|
39
|
+
if (cls) c.className = cls;
|
|
40
|
+
if (title) c.title = title;
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 卡片状态层:与 app.js 的 setCardState 同构(loading/empty/error/ready),空态文案按会话语境定制 */
|
|
45
|
+
function setCardState(cardEl, name, msg, retryFn, emptyText) {
|
|
46
|
+
var layer = cardEl.querySelector('.card-state');
|
|
47
|
+
if (!layer) return;
|
|
48
|
+
layer.className = 'card-state state-' + name;
|
|
49
|
+
if (name === 'ready') { layer.innerHTML = ''; hide(layer); return; }
|
|
50
|
+
show(layer);
|
|
51
|
+
if (name === 'loading') {
|
|
52
|
+
layer.innerHTML = '<div class="skeleton"></div><div class="skeleton sk-60"></div><div class="skeleton sk-40"></div>';
|
|
53
|
+
} else if (name === 'empty') {
|
|
54
|
+
layer.innerHTML = '<div class="state-text">' + (emptyText || '该窗口暂无会话上报') + '</div>';
|
|
55
|
+
} else if (name === 'error') {
|
|
56
|
+
layer.innerHTML = '';
|
|
57
|
+
var div = document.createElement('div');
|
|
58
|
+
div.className = 'state-text state-error-text';
|
|
59
|
+
div.textContent = msg || '加载失败';
|
|
60
|
+
var btn = document.createElement('button');
|
|
61
|
+
btn.className = 'btn btn-sm';
|
|
62
|
+
btn.textContent = '重试';
|
|
63
|
+
btn.addEventListener('click', retryFn || load);
|
|
64
|
+
layer.appendChild(div);
|
|
65
|
+
layer.appendChild(btn);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- token 口径 ----------
|
|
70
|
+
/** 输入总量 = 输入 + 缓存读 + 缓存写(面板侧派生,与服务端 tokens_input_total 同口径) */
|
|
71
|
+
function inputTotal(r) {
|
|
72
|
+
return (r.tokens_input || 0) + (r.tokens_cache_read || 0) + (r.tokens_cache_write || 0);
|
|
73
|
+
}
|
|
74
|
+
/** 输出总量 = 输出 + 推理(推理单独列示,不并入输出) */
|
|
75
|
+
function outputTotal(r) {
|
|
76
|
+
return (r.tokens_output || 0) + (r.tokens_reasoning || 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- 加载编排 ----------
|
|
80
|
+
/** 整页加载:列表重置到第一页 + 对账聚合;seq 保证切窗/切筛选后的旧响应不回写 */
|
|
81
|
+
function load() {
|
|
82
|
+
var cfg = deps && deps.getCfg();
|
|
83
|
+
if (!cfg) return Promise.resolve();
|
|
84
|
+
var seq = ++st.seq;
|
|
85
|
+
st.period = deps.getPeriod();
|
|
86
|
+
setCardState(el.coverageCard, 'loading');
|
|
87
|
+
setCardState(el.listCard, 'loading');
|
|
88
|
+
st.rows = [];
|
|
89
|
+
st.nextCursor = null;
|
|
90
|
+
el.tbody.innerHTML = '';
|
|
91
|
+
el.count.textContent = '';
|
|
92
|
+
hide(el.btnMore);
|
|
93
|
+
closeDetail();
|
|
94
|
+
var w = st.period;
|
|
95
|
+
return Promise.all([
|
|
96
|
+
fetchPage(cfg, w, null, true, seq),
|
|
97
|
+
fetchCoverage(cfg, w, seq)
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 拉取一页会话:isReset=true 首页(覆盖),false 追加(加载更多);失败走卡片错误层 + 重试 */
|
|
102
|
+
function fetchPage(cfg, window_, cursor, isReset, seq) {
|
|
103
|
+
var params = { window: window_, limit: PAGE_SIZE };
|
|
104
|
+
if (st.filters.model) params.model = st.filters.model;
|
|
105
|
+
if (st.filters.agent) params.agent = st.filters.agent;
|
|
106
|
+
if (cursor) params.cursor = cursor;
|
|
107
|
+
return API.sessions(cfg, params).then(function (res) {
|
|
108
|
+
if (seq !== st.seq) return;
|
|
109
|
+
var rows = (res && res.data) || [];
|
|
110
|
+
st.rows = isReset ? rows : st.rows.concat(rows);
|
|
111
|
+
st.nextCursor = (res && res.next_cursor) || null;
|
|
112
|
+
absorbOptions(rows);
|
|
113
|
+
renderRows();
|
|
114
|
+
el.count.textContent = '已加载 ' + st.rows.length + ' 个会话' + (st.nextCursor ? '(还有更多)' : '');
|
|
115
|
+
if (st.nextCursor) show(el.btnMore); else hide(el.btnMore);
|
|
116
|
+
if (isReset && !rows.length) {
|
|
117
|
+
setCardState(el.listCard, 'empty', null, null,
|
|
118
|
+
(st.filters.model || st.filters.agent) ? '当前筛选条件下暂无会话上报' : null);
|
|
119
|
+
} else {
|
|
120
|
+
setCardState(el.listCard, 'ready');
|
|
121
|
+
}
|
|
122
|
+
updateStamp(res && res.as_of);
|
|
123
|
+
}, function (err) {
|
|
124
|
+
if (seq !== st.seq) return;
|
|
125
|
+
if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
|
|
126
|
+
setCardState(el.listCard, 'error', (err && err.message) || '加载失败', function () {
|
|
127
|
+
fetchPage(deps.getCfg(), deps.getPeriod(), cursor, isReset, st.seq);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 对账聚合:网关权威总量(/v1/usage/summary)− 会话上报合计(Σ /v1/sessions/summary)= 未归属用量 */
|
|
133
|
+
function fetchCoverage(cfg, window_, seq) {
|
|
134
|
+
function settle(p) {
|
|
135
|
+
return p.then(function (v) { return v; }, function (e) { return { __error: e }; });
|
|
136
|
+
}
|
|
137
|
+
return Promise.all([
|
|
138
|
+
settle(API.summary(cfg, window_)),
|
|
139
|
+
settle(API.sessionsSummary(cfg, { window: window_, group_by: 'model' }))
|
|
140
|
+
]).then(function (rs) {
|
|
141
|
+
if (seq !== st.seq) return;
|
|
142
|
+
var gwErr = rs[0].__error, sesErr = rs[1].__error;
|
|
143
|
+
if ((gwErr && gwErr.code === 'unauthorized') || (sesErr && sesErr.code === 'unauthorized')) {
|
|
144
|
+
deps.onUnauthorized(UNAUTH_MSG);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (gwErr && sesErr) {
|
|
148
|
+
setCardState(el.coverageCard, 'error', (gwErr.message || sesErr.message) || '加载失败', function () {
|
|
149
|
+
fetchCoverage(deps.getCfg(), deps.getPeriod(), st.seq);
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
var gw = gwErr ? null : ((rs[0].data.req_tokens || 0) + (rs[0].data.rsp_tokens || 0));
|
|
154
|
+
var ses = 0, cnt = 0;
|
|
155
|
+
if (!sesErr) {
|
|
156
|
+
(rs[1].data || []).forEach(function (row) {
|
|
157
|
+
ses += inputTotal(row) + outputTotal(row);
|
|
158
|
+
cnt += (row.sessions || 0);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
renderCoverage(gw, ses, cnt, gwErr, sesErr);
|
|
162
|
+
setCardState(el.coverageCard, 'ready');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------- 对账卡片 ----------
|
|
167
|
+
function renderCoverage(gw, ses, cnt, gwErr, sesErr) {
|
|
168
|
+
var cfg = deps.getCfg() || {};
|
|
169
|
+
function set(node, v, tip) {
|
|
170
|
+
node.textContent = v === null ? '--' : F.abbrev(v, cfg.numberStyle);
|
|
171
|
+
node.title = v === null ? '' : tip;
|
|
172
|
+
}
|
|
173
|
+
set(el.covGateway, gw,
|
|
174
|
+
gw === null ? '' : '网关权威口径:/v1/usage/summary 的 req_tokens + rsp_tokens = ' + F.fullNumber(gw));
|
|
175
|
+
set(el.covReported, sesErr ? null : ses,
|
|
176
|
+
'会话上报合计:Σ /v1/sessions/summary 各组(输入总量 + 输出总量)= ' + F.fullNumber(ses) +
|
|
177
|
+
(cnt ? ',共 ' + cnt + ' 个会话' : ''));
|
|
178
|
+
var diff = (gw === null || sesErr) ? null : (gw - ses);
|
|
179
|
+
set(el.covDiff, diff,
|
|
180
|
+
diff === null ? '' : '网关权威总量 − 会话上报合计 = ' + F.fullNumber(diff));
|
|
181
|
+
el.covRate.textContent = (gw === null || sesErr || gw <= 0) ? '--' : F.hitRate(ses / gw);
|
|
182
|
+
el.covRate.title = (gw === null || sesErr || gw <= 0) ? '' : '会话上报合计 ÷ 网关权威总量';
|
|
183
|
+
|
|
184
|
+
var note = '未归属用量可能来自:标题生成、会话压缩、重试与其他客户端';
|
|
185
|
+
if (diff !== null && diff < 0) note += ';会话上报合计高于网关总量,可能为口径差异或重复上报';
|
|
186
|
+
if (gwErr) note = '网关总量获取失败;' + note;
|
|
187
|
+
if (sesErr) note = '会话汇总获取失败;' + note;
|
|
188
|
+
if (!sesErr && ses === 0) note = '当前窗口暂无会话上报;' + note;
|
|
189
|
+
el.covNote.textContent = note;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- 会话列表 ----------
|
|
193
|
+
function renderRows() {
|
|
194
|
+
var tbody = el.tbody;
|
|
195
|
+
tbody.innerHTML = '';
|
|
196
|
+
st.rows.forEach(function (r) { tbody.appendChild(sessionRow(r)); });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sessionRow(r) {
|
|
200
|
+
var tr = document.createElement('tr');
|
|
201
|
+
// 会话 ID:等宽截断展示,完整值放 title,点击查看详情
|
|
202
|
+
var sidTd = document.createElement('td');
|
|
203
|
+
var sidBtn = document.createElement('button');
|
|
204
|
+
sidBtn.type = 'button';
|
|
205
|
+
sidBtn.className = 'sess-id-btn mono';
|
|
206
|
+
sidBtn.textContent = r.session_id || '--';
|
|
207
|
+
sidBtn.title = (r.session_id || '') + '\n点击查看详情';
|
|
208
|
+
sidBtn.addEventListener('click', function () { openDetail(r.session_id); });
|
|
209
|
+
sidTd.appendChild(sidBtn);
|
|
210
|
+
tr.appendChild(sidTd);
|
|
211
|
+
|
|
212
|
+
tr.appendChild(td(r.model || '--'));
|
|
213
|
+
tr.appendChild(td(r.agent || '--'));
|
|
214
|
+
tr.appendChild(td(F.fullNumber(inputTotal(r)), 'num', '输入总量 = 输入 + 缓存读 + 缓存写'));
|
|
215
|
+
tr.appendChild(td(F.fullNumber(outputTotal(r)), 'num', '输出总量 = 输出 + 推理'));
|
|
216
|
+
tr.appendChild(td(F.fullNumber(r.tokens_reasoning), 'num'));
|
|
217
|
+
tr.appendChild(td(F.fullNumber(r.tokens_cache_read), 'num'));
|
|
218
|
+
tr.appendChild(td(F.fullNumber(r.tokens_cache_write), 'num'));
|
|
219
|
+
tr.appendChild(td(F.fullNumber(r.message_count), 'num'));
|
|
220
|
+
tr.appendChild(td(F.dateTime(r.last_activity_at)));
|
|
221
|
+
return tr;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 头部“更新于”时间戳:跟随当前视图数据(as_of 为 ISO 字符串,解析失败回退当前时间) */
|
|
225
|
+
function updateStamp(asOf) {
|
|
226
|
+
var target = $('last-updated');
|
|
227
|
+
if (!target) return;
|
|
228
|
+
var t = asOf ? Date.parse(asOf) : NaN;
|
|
229
|
+
if (!isFinite(t)) t = Date.now();
|
|
230
|
+
target.textContent = '更新于 ' + F.timeOfDay(Math.floor(t / 1000));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- 筛选项 ----------
|
|
234
|
+
/** 收集筛选项:模型 = 模型目录 ∪ 已见会话;代理 = 已见会话;仅在出现新值时重建下拉 */
|
|
235
|
+
function absorbOptions(rows) {
|
|
236
|
+
var modelNew = false, agentNew = false;
|
|
237
|
+
rows.forEach(function (r) {
|
|
238
|
+
if (r.model && !st.modelSet[r.model]) { st.modelSet[r.model] = true; modelNew = true; }
|
|
239
|
+
if (r.agent && !st.agentSet[r.agent]) { st.agentSet[r.agent] = true; agentNew = true; }
|
|
240
|
+
});
|
|
241
|
+
(deps.getModelCatalog() || []).forEach(function (m) {
|
|
242
|
+
if (m && m.id && !st.modelSet[m.id]) { st.modelSet[m.id] = true; modelNew = true; }
|
|
243
|
+
});
|
|
244
|
+
if (modelNew) rebuildSelect(el.filterModel, st.modelSet, '全部模型');
|
|
245
|
+
if (agentNew) rebuildSelect(el.filterAgent, st.agentSet, '全部代理');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function rebuildSelect(sel, set, allLabel) {
|
|
249
|
+
var cur = sel.value;
|
|
250
|
+
sel.innerHTML = '';
|
|
251
|
+
var all = document.createElement('option');
|
|
252
|
+
all.value = '';
|
|
253
|
+
all.textContent = allLabel;
|
|
254
|
+
sel.appendChild(all);
|
|
255
|
+
Object.keys(set).sort().forEach(function (k) {
|
|
256
|
+
var o = document.createElement('option');
|
|
257
|
+
o.value = k;
|
|
258
|
+
o.textContent = k;
|
|
259
|
+
sel.appendChild(o);
|
|
260
|
+
});
|
|
261
|
+
sel.value = set[cur] ? cur : ''; // 保留当前选择
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------- 会话详情 ----------
|
|
265
|
+
function openDetail(id) {
|
|
266
|
+
if (!id) return;
|
|
267
|
+
var cfg = deps.getCfg();
|
|
268
|
+
if (!cfg) return;
|
|
269
|
+
st.selectedId = id;
|
|
270
|
+
el.detailId.textContent = id;
|
|
271
|
+
el.detailBody.innerHTML = '';
|
|
272
|
+
show(el.detailCard);
|
|
273
|
+
setCardState(el.detailCard, 'loading');
|
|
274
|
+
if (el.detailCard.scrollIntoView) {
|
|
275
|
+
try { el.detailCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
|
276
|
+
catch (e) { el.detailCard.scrollIntoView(); }
|
|
277
|
+
}
|
|
278
|
+
API.sessionDetail(cfg, id).then(function (res) {
|
|
279
|
+
if (st.selectedId !== id) return;
|
|
280
|
+
renderDetail((res && res.data) || {});
|
|
281
|
+
setCardState(el.detailCard, 'ready');
|
|
282
|
+
}, function (err) {
|
|
283
|
+
if (st.selectedId !== id) return;
|
|
284
|
+
if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
|
|
285
|
+
var msg = (err && err.status === 404)
|
|
286
|
+
? '会话不存在或不在当前消费者名下'
|
|
287
|
+
: ((err && err.message) || '加载失败');
|
|
288
|
+
setCardState(el.detailCard, 'error', msg, function () { openDetail(id); });
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function closeDetail() {
|
|
293
|
+
st.selectedId = null;
|
|
294
|
+
el.detailBody.innerHTML = '';
|
|
295
|
+
hide(el.detailCard);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderDetail(d) {
|
|
299
|
+
el.detailId.textContent = d.session_id || '--';
|
|
300
|
+
var body = el.detailBody;
|
|
301
|
+
body.innerHTML = '';
|
|
302
|
+
|
|
303
|
+
body.appendChild(group('Token 明细', [
|
|
304
|
+
item('输入', F.fullNumber(d.tokens_input)),
|
|
305
|
+
item('缓存读', F.fullNumber(d.tokens_cache_read)),
|
|
306
|
+
item('缓存写', F.fullNumber(d.tokens_cache_write)),
|
|
307
|
+
item('输入总量(派生)', F.fullNumber(inputTotal(d)), false, '输入总量 = 输入 + 缓存读 + 缓存写'),
|
|
308
|
+
item('输出', F.fullNumber(d.tokens_output)),
|
|
309
|
+
item('推理', F.fullNumber(d.tokens_reasoning)),
|
|
310
|
+
item('输出总量(派生)', F.fullNumber(outputTotal(d)), false, '输出总量 = 输出 + 推理'),
|
|
311
|
+
item('消息数', F.fullNumber(d.message_count))
|
|
312
|
+
]));
|
|
313
|
+
|
|
314
|
+
body.appendChild(group('会话信息', [
|
|
315
|
+
item('模型', d.model || '--'),
|
|
316
|
+
item('代理', d.agent || '--'),
|
|
317
|
+
item('安装标识', d.install_id || '--', true),
|
|
318
|
+
item('来源', d.source || '--'),
|
|
319
|
+
item('修订号', F.fullNumber(d.revision)),
|
|
320
|
+
item('Schema 版本', (d.schema_version === undefined || d.schema_version === null) ? '--' : String(d.schema_version)),
|
|
321
|
+
item('上报器版本', d.reporter_version || '--')
|
|
322
|
+
]));
|
|
323
|
+
|
|
324
|
+
body.appendChild(group('时间', [
|
|
325
|
+
item('会话创建', F.dateTime(d.session_created_at)),
|
|
326
|
+
item('最后活动', F.dateTime(d.last_activity_at)),
|
|
327
|
+
item('客户端上报', F.dateTime(d.client_reported_at)),
|
|
328
|
+
item('首次收录', F.dateTime(d.first_seen_at)),
|
|
329
|
+
item('最近上报', F.dateTime(d.last_reported_at))
|
|
330
|
+
]));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function group(title, items) {
|
|
334
|
+
var g = document.createElement('div');
|
|
335
|
+
g.className = 'sess-detail-group';
|
|
336
|
+
var h = document.createElement('div');
|
|
337
|
+
h.className = 'sess-detail-heading';
|
|
338
|
+
h.textContent = title;
|
|
339
|
+
var grid = document.createElement('div');
|
|
340
|
+
grid.className = 'sess-detail-grid';
|
|
341
|
+
items.forEach(function (it) { grid.appendChild(it); });
|
|
342
|
+
g.appendChild(h);
|
|
343
|
+
g.appendChild(grid);
|
|
344
|
+
return g;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function item(label, value, mono, tip) {
|
|
348
|
+
var wrap = document.createElement('div');
|
|
349
|
+
wrap.className = 'sess-detail-item';
|
|
350
|
+
var l = document.createElement('div');
|
|
351
|
+
l.className = 'kpi-label';
|
|
352
|
+
l.textContent = label;
|
|
353
|
+
var v = document.createElement('div');
|
|
354
|
+
v.className = 'sess-detail-value' + (mono ? ' mono' : '');
|
|
355
|
+
v.textContent = value;
|
|
356
|
+
if (tip) v.title = tip;
|
|
357
|
+
wrap.appendChild(l);
|
|
358
|
+
wrap.appendChild(v);
|
|
359
|
+
return wrap;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------- 初始化 ----------
|
|
363
|
+
function init(d) {
|
|
364
|
+
deps = d;
|
|
365
|
+
el = {
|
|
366
|
+
coverageCard: $('card-session-coverage'),
|
|
367
|
+
listCard: $('card-session-list'),
|
|
368
|
+
detailCard: $('card-session-detail'),
|
|
369
|
+
tbody: $('session-tbody'),
|
|
370
|
+
btnMore: $('btn-sess-more'),
|
|
371
|
+
count: $('sess-count'),
|
|
372
|
+
filterModel: $('sess-filter-model'),
|
|
373
|
+
filterAgent: $('sess-filter-agent'),
|
|
374
|
+
detailBody: $('sess-detail-body'),
|
|
375
|
+
detailId: $('sess-detail-id'),
|
|
376
|
+
covGateway: $('sess-cov-gateway'),
|
|
377
|
+
covReported: $('sess-cov-reported'),
|
|
378
|
+
covDiff: $('sess-cov-diff'),
|
|
379
|
+
covRate: $('sess-cov-rate'),
|
|
380
|
+
covNote: $('sess-cov-note')
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
el.btnMore.addEventListener('click', function () {
|
|
384
|
+
if (!st.nextCursor) return;
|
|
385
|
+
el.btnMore.disabled = true;
|
|
386
|
+
el.btnMore.textContent = '加载中…';
|
|
387
|
+
// fetchPage 内部消化错误,此处仅恢复按钮状态
|
|
388
|
+
fetchPage(deps.getCfg(), deps.getPeriod(), st.nextCursor, false, st.seq)
|
|
389
|
+
.then(function () {
|
|
390
|
+
el.btnMore.disabled = false;
|
|
391
|
+
el.btnMore.textContent = '加载更多';
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
el.filterModel.addEventListener('change', function () {
|
|
396
|
+
st.filters.model = this.value;
|
|
397
|
+
load();
|
|
398
|
+
});
|
|
399
|
+
el.filterAgent.addEventListener('change', function () {
|
|
400
|
+
st.filters.agent = this.value;
|
|
401
|
+
load();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
$('btn-sess-detail-close').addEventListener('click', closeDetail);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
window.BRU = window.BRU || {};
|
|
408
|
+
window.BRU.sessions = {
|
|
409
|
+
init: init,
|
|
410
|
+
load: load
|
|
411
|
+
};
|
|
412
|
+
})();
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { readConfig, setConfig, maskKey } from "./config.js"
|
|
5
|
+
import { dbFile, panelDir, pluginSource, opencodePluginsDir, PKG_ROOT } from "./paths.js"
|
|
6
|
+
import { Store } from "./store.js"
|
|
7
|
+
import { createServer } from "./server.js"
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PORT = 8787
|
|
10
|
+
const DEFAULT_HOST = "127.0.0.1"
|
|
11
|
+
|
|
12
|
+
function version() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version
|
|
15
|
+
} catch {
|
|
16
|
+
return "0.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseFlags(argv) {
|
|
21
|
+
const flags = { _: [] }
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i]
|
|
24
|
+
if (a.startsWith("--")) {
|
|
25
|
+
const eq = a.indexOf("=")
|
|
26
|
+
if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1)
|
|
27
|
+
else {
|
|
28
|
+
const next = argv[i + 1]
|
|
29
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
30
|
+
flags[a.slice(2)] = next
|
|
31
|
+
i++
|
|
32
|
+
} else flags[a.slice(2)] = true
|
|
33
|
+
}
|
|
34
|
+
} else if (a === "-h") flags.help = true
|
|
35
|
+
else if (a === "-v") flags.version = true
|
|
36
|
+
else flags._.push(a)
|
|
37
|
+
}
|
|
38
|
+
return flags
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HELP = `opencode-stats-reporter — 本地一体化 MaaS 用量看板
|
|
42
|
+
|
|
43
|
+
用法:
|
|
44
|
+
opencode-stats-reporter config set --gateway <url> --key <key> 配置上游网关与 Key
|
|
45
|
+
opencode-stats-reporter config get 查看配置(Key 脱敏)
|
|
46
|
+
opencode-stats-reporter web start [--port 8787] [--host 127.0.0.1] [--open]
|
|
47
|
+
启动本地服务并托管面板
|
|
48
|
+
opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件
|
|
49
|
+
|
|
50
|
+
选项:
|
|
51
|
+
-h, --help 显示帮助
|
|
52
|
+
-v, --version 显示版本
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
function cmdConfig(argv) {
|
|
56
|
+
const sub = argv[0]
|
|
57
|
+
const flags = parseFlags(argv.slice(1))
|
|
58
|
+
if (sub === "set") {
|
|
59
|
+
const cfg = setConfig({ gateway: flags.gateway, key: flags.key })
|
|
60
|
+
console.log("已写入配置: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
61
|
+
console.log("API Key: " + maskKey(cfg.apiKey))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (sub === "get") {
|
|
65
|
+
const cfg = readConfig()
|
|
66
|
+
console.log("gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
67
|
+
console.log("apiKey: " + maskKey(cfg.apiKey))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
throw new Error("用法: opencode-stats-reporter config set|get")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function cmdWeb(argv) {
|
|
74
|
+
const sub = argv[0]
|
|
75
|
+
if (sub !== "start") throw new Error("用法: opencode-stats-reporter web start")
|
|
76
|
+
const flags = parseFlags(argv.slice(1))
|
|
77
|
+
const cfg = readConfig()
|
|
78
|
+
if (!cfg.gatewayBaseUrl || !cfg.apiKey) {
|
|
79
|
+
throw new Error("尚未配置上游网关,请先运行: opencode-stats-reporter config set --gateway <url> --key <key>")
|
|
80
|
+
}
|
|
81
|
+
const port = Number(flags.port || DEFAULT_PORT)
|
|
82
|
+
const host = String(flags.host || DEFAULT_HOST)
|
|
83
|
+
const mode = String(flags.mode || process.env.SESSION_USAGE_MODE || "local")
|
|
84
|
+
if (mode !== "local" && mode !== "gateway") throw new Error("--mode 必须是 local 或 gateway")
|
|
85
|
+
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1"
|
|
86
|
+
|
|
87
|
+
const store = new Store(dbFile())
|
|
88
|
+
store.migrate()
|
|
89
|
+
const server = createServer({ config: cfg, store, panelDir: panelDir(), mode, consumerHeader: flags["consumer-header"] || null })
|
|
90
|
+
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
server.once("error", reject)
|
|
93
|
+
server.listen(port, host, resolve)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`
|
|
97
|
+
console.log("本地看板已启动: " + url)
|
|
98
|
+
console.log("上游网关: " + cfg.gatewayBaseUrl)
|
|
99
|
+
console.log("数据文件: " + dbFile())
|
|
100
|
+
if (!loopback) console.warn("警告: 正在监听非回环地址 " + host + ",本机网络内可访问,请自行确认风险")
|
|
101
|
+
if (flags.open) openBrowser(url)
|
|
102
|
+
|
|
103
|
+
process.on("SIGINT", () => {
|
|
104
|
+
console.log("\n正在关闭…")
|
|
105
|
+
store.close()
|
|
106
|
+
server.close(() => process.exit(0))
|
|
107
|
+
setTimeout(() => process.exit(0), 1000).unref()
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cmdPlugin(argv) {
|
|
112
|
+
const sub = argv[0]
|
|
113
|
+
if (sub !== "install") throw new Error("用法: opencode-stats-reporter plugin install")
|
|
114
|
+
const flags = parseFlags(argv.slice(1))
|
|
115
|
+
const port = Number(flags.port || DEFAULT_PORT)
|
|
116
|
+
const src = pluginSource()
|
|
117
|
+
if (!fs.existsSync(src)) throw new Error("包内未找到上报插件: " + src)
|
|
118
|
+
const dir = opencodePluginsDir()
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
120
|
+
const dest = path.join(dir, "session-usage-reporter.ts")
|
|
121
|
+
const content = fs.readFileSync(src)
|
|
122
|
+
const same = fs.existsSync(dest) && Buffer.compare(fs.readFileSync(dest), content) === 0
|
|
123
|
+
if (same) {
|
|
124
|
+
console.log("插件已是最新: " + dest)
|
|
125
|
+
} else {
|
|
126
|
+
fs.writeFileSync(dest, content)
|
|
127
|
+
console.log("已安装插件: " + dest)
|
|
128
|
+
}
|
|
129
|
+
console.log("")
|
|
130
|
+
console.log("推荐用 npm 方式安装(可随 npm 更新、支持 -f 覆盖):")
|
|
131
|
+
console.log(" opencode plugin @dijkspicy/opencode-stats-reporter -g")
|
|
132
|
+
console.log("")
|
|
133
|
+
console.log("以上为离线/本地文件安装方式。之后在 opencode 进程环境中设置:")
|
|
134
|
+
console.log(" SESSION_USAGE_ENDPOINT=http://127.0.0.1:" + port + "/v1/session-usage/report")
|
|
135
|
+
console.log("(本地模式无需 SESSION_USAGE_API_KEY;请勿使用 --pure 启动 opencode)")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function openBrowser(url) {
|
|
139
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"
|
|
140
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]
|
|
141
|
+
try {
|
|
142
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref()
|
|
143
|
+
} catch {
|
|
144
|
+
/* 打不开就算了 */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function main(argv) {
|
|
149
|
+
const flags = parseFlags(argv)
|
|
150
|
+
if (flags.version) {
|
|
151
|
+
console.log(version())
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (flags.help || argv.length === 0) {
|
|
155
|
+
console.log(HELP)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const [cmd, ...rest] = argv
|
|
159
|
+
if (cmd === "config") return cmdConfig(rest)
|
|
160
|
+
if (cmd === "web") return cmdWeb(rest)
|
|
161
|
+
if (cmd === "plugin") return cmdPlugin(rest)
|
|
162
|
+
console.log(HELP)
|
|
163
|
+
throw new Error("未知命令: " + cmd)
|
|
164
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import { configDir, configFile } from "./paths.js"
|
|
3
|
+
|
|
4
|
+
export function readConfig() {
|
|
5
|
+
try {
|
|
6
|
+
const raw = fs.readFileSync(configFile(), "utf8")
|
|
7
|
+
const obj = JSON.parse(raw)
|
|
8
|
+
return obj && typeof obj === "object" ? obj : {}
|
|
9
|
+
} catch {
|
|
10
|
+
return {}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 原子写入,权限 600 */
|
|
15
|
+
export function writeConfig(cfg) {
|
|
16
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 })
|
|
17
|
+
const tmp = configFile() + ".tmp"
|
|
18
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 })
|
|
19
|
+
fs.renameSync(tmp, configFile())
|
|
20
|
+
try {
|
|
21
|
+
fs.chmodSync(configFile(), 0o600)
|
|
22
|
+
} catch {
|
|
23
|
+
/* 平台不支持时忽略 */
|
|
24
|
+
}
|
|
25
|
+
return cfg
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validUrl(url) {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url)
|
|
31
|
+
return u.protocol === "http:" || u.protocol === "https:"
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setConfig({ gateway, key }) {
|
|
38
|
+
const cfg = readConfig()
|
|
39
|
+
if (gateway !== undefined) {
|
|
40
|
+
const g = String(gateway).trim().replace(/\/+$/, "")
|
|
41
|
+
if (!validUrl(g)) throw new Error("gateway 必须是合法的 http(s) URL")
|
|
42
|
+
cfg.gatewayBaseUrl = g
|
|
43
|
+
}
|
|
44
|
+
if (key !== undefined) {
|
|
45
|
+
const k = String(key).trim()
|
|
46
|
+
if (!k) throw new Error("key 不能为空")
|
|
47
|
+
cfg.apiKey = k
|
|
48
|
+
}
|
|
49
|
+
return writeConfig(cfg)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function maskKey(key) {
|
|
53
|
+
const k = String(key || "")
|
|
54
|
+
if (!k) return "(未设置)"
|
|
55
|
+
if (k.length <= 8) return "****"
|
|
56
|
+
return k.slice(0, 4) + "****" + k.slice(-4)
|
|
57
|
+
}
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { LOCAL_CONSUMER } from "./store.js"
|
|
2
|
+
|
|
3
|
+
// 与网关侧一致的消费者 ID 形态;不匹配者一律不认
|
|
4
|
+
export const CONSUMER_PATTERN = /^api-key-[A-Za-z0-9]{6,32}$/
|
|
5
|
+
|
|
6
|
+
export function normalizeConsumer(raw) {
|
|
7
|
+
if (!raw) return null
|
|
8
|
+
let c = String(raw).trim().split(",")[0].trim()
|
|
9
|
+
if (c.toLowerCase().startsWith("bearer ")) c = c.slice(7).trim()
|
|
10
|
+
return CONSUMER_PATTERN.test(c) ? c : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 身份解析:
|
|
15
|
+
* - mode=gateway(网关托管):只认网关注入的身份头,取不到即 fail-closed 返回 null → 401
|
|
16
|
+
* - mode=local(本地单用户):取不到身份时归入单一本地用户
|
|
17
|
+
* - SESSION_USAGE_CONSUMER_HEADER 显式指定头名时,只认该头且 fail-closed(与 Python 版行为一致)
|
|
18
|
+
*/
|
|
19
|
+
export function createIdentityResolver({ mode = "local", headerName = null } = {}) {
|
|
20
|
+
return function resolveConsumer(req) {
|
|
21
|
+
const forced = (headerName || process.env.SESSION_USAGE_CONSUMER_HEADER || "").trim()
|
|
22
|
+
if (forced) {
|
|
23
|
+
return normalizeConsumer(req.headers[forced.toLowerCase()])
|
|
24
|
+
}
|
|
25
|
+
for (const header of ["x-forward-consumer"]) {
|
|
26
|
+
const c = normalizeConsumer(req.headers[header])
|
|
27
|
+
if (c) return c
|
|
28
|
+
}
|
|
29
|
+
// 本地开发回退:Authorization: Bearer api-key-xxx(网关上的 Key 是 UUID,过不了白名单,不会误认)
|
|
30
|
+
const c = normalizeConsumer(req.headers["authorization"])
|
|
31
|
+
if (c) return c
|
|
32
|
+
return mode === "local" ? LOCAL_CONSUMER : null
|
|
33
|
+
}
|
|
34
|
+
}
|