@weiping/mbridge-page 0.1.1 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weiping/mbridge-page",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "mbridge's usage at a glance inside magpie: what magpie's ledger recorded, and where mbridge's own status page is.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/page/app.js CHANGED
@@ -8,6 +8,21 @@
8
8
  (async () => {
9
9
  const V = globalThis.MbridgeView;
10
10
  const $ = (id) => document.getElementById(id);
11
+ // el makes an element with text and class
12
+ function el(tag, attrs, kids) {
13
+ const n = document.createElement(tag);
14
+ for (const [k, v] of Object.entries(attrs || {})) {
15
+ if (v === undefined || v === null) continue;
16
+ if (k === "text") n.textContent = v;
17
+ else if (k === "class") n.className = v;
18
+ else n.setAttribute(k, v);
19
+ }
20
+ for (const kid of [].concat(kids || [])) {
21
+ if (kid === undefined || kid === null) continue;
22
+ n.appendChild(typeof kid === "object" ? kid : document.createTextNode(String(kid)));
23
+ }
24
+ return n;
25
+ }
11
26
  const theme = await magpie.ready;
12
27
  let lang = theme.lang;
13
28
 
@@ -24,6 +39,10 @@
24
39
  sort: V.SORTS.includes(opts.sort) ? opts.sort : "tokens",
25
40
  summary: null,
26
41
  error: null,
42
+ // what mbridge published into the options (SP14 §7)
43
+ mbridge: null,
44
+ // the sequence number of the last command the page sent
45
+ cmdSeq: 0,
27
46
  };
28
47
 
29
48
  function words() {
@@ -42,9 +61,83 @@
42
61
  });
43
62
  }
44
63
 
64
+ // paintMbridge draws what mbridge published: its numbers, the sessions
65
+ // running now, and the decisions it made.
66
+ function paintMbridge() {
67
+ const t = V.text(lang);
68
+ const v = V.mbridgeView(state.mbridge, lang);
69
+ $("mbridge-age").textContent = v.none ? "" : (t.mbridgeAge + " " + v.ageText);
70
+ $("mbridge-empty").textContent = v.none ? v.text : (v.noDecisions ? t.noDecisions : "");
71
+ $("mbridge-empty").hidden = !(v.none || v.noDecisions);
72
+ const stats = $("mbridge-stats");
73
+ stats.textContent = "";
74
+ if (!v.none) {
75
+ const items = [
76
+ [String(v.turnCount), t.mbridgeTurns],
77
+ [v.coverage, t.mbridgeCoverage],
78
+ [String(v.failures), t.mbridgeFailures],
79
+ [v.topReason || "—", t.reason],
80
+ ];
81
+ for (const [value, label] of items) {
82
+ const cell = document.createElement("div");
83
+ cell.className = "stat";
84
+ const b = document.createElement("b");
85
+ b.textContent = value;
86
+ const s2 = document.createElement("span");
87
+ s2.textContent = label;
88
+ cell.append(b, s2);
89
+ stats.append(cell);
90
+ }
91
+ }
92
+ const live = $("mbridge-live");
93
+ live.textContent = "";
94
+ if (v.none) {
95
+ live.append(el("div", { class: "muted", text: t.mbridgeNone }));
96
+ } else if (v.emptyLive) {
97
+ live.append(el("div", { class: "muted", text: t.mbridgeNoLive }));
98
+ } else {
99
+ for (const c of v.live) {
100
+ const bits = [];
101
+ if (c.reason) bits.push(c.reason);
102
+ if (c.conf !== undefined && c.conf !== null) bits.push("conf " + c.conf.toFixed(2));
103
+ if (c.tools !== null && c.tools !== undefined) bits.push(t.tools + " " + c.tools);
104
+ live.append(el("div", { class: "card" }, [
105
+ el("div", { class: "row1" }, [
106
+ el("span", { text: c.harness + " · " + c.agent }),
107
+ el("span", { text: c.tier + (c.group ? " → " + c.group : "") }),
108
+ ]),
109
+ el("div", { class: "row2", text: c.session + " · " + bits.join(" · ") }),
110
+ ]));
111
+ }
112
+ }
113
+ const body = $("mbridge-decisions");
114
+ body.textContent = "";
115
+ for (const d of v.decisions || []) {
116
+ const tr = document.createElement("tr");
117
+ const cells = [
118
+ [d.clock, "muted"],
119
+ [d.harness, ""],
120
+ [d.agent, ""],
121
+ [d.tier, ""],
122
+ [d.advised, d.differs ? "differs" : "muted"],
123
+ [d.reason, "muted"],
124
+ [d.conf === undefined || d.conf === null ? "—" : d.conf.toFixed(2), "num"],
125
+ ];
126
+ for (const [text, cls] of cells) {
127
+ const td = document.createElement("td");
128
+ if (cls) td.className = cls;
129
+ td.textContent = text;
130
+ tr.append(td);
131
+ }
132
+ body.append(tr);
133
+ }
134
+ }
135
+
45
136
  function paint() {
46
137
  pick("periods", "period", state.period);
47
138
  pick("sorts", "sort", state.sort);
139
+ // mbridge's part does not depend on magpie's usage
140
+ paintMbridge();
48
141
  const s = state.summary;
49
142
  if (!s) return;
50
143
  $("since").textContent = V.since(s, lang);
@@ -98,16 +191,55 @@
98
191
  }
99
192
  }
100
193
 
101
- async function load() {
194
+ // readOptions reads the plugin's options: the page's period and sort,
195
+ // and what mbridge published there.
196
+ async function readOptions() {
197
+ try {
198
+ const o = (await magpie.options.get()) || {};
199
+ // what we write back must be what we just read: writing a stale
200
+ // copy would put an old snapshot back
201
+ opts = o;
202
+ if (V.PERIODS.includes(o.period)) state.period = o.period;
203
+ if (V.SORTS.includes(o.sort)) state.sort = o.sort;
204
+ state.mbridge = o.mbridge || null;
205
+ } catch {
206
+ // the options are not readable: keep what we have
207
+ }
208
+ }
209
+
210
+ // loadUsage reads what magpie recorded; it moves slowly.
211
+ async function loadUsage() {
102
212
  try {
103
213
  state.summary = await magpie.read("usage:summary", { period: state.period });
104
214
  state.error = null;
105
215
  } catch (e) {
106
216
  state.error = (e && e.message) || String(e);
107
217
  }
218
+ }
219
+
220
+ async function load() {
221
+ await readOptions();
222
+ await loadUsage();
108
223
  paint();
109
224
  }
110
225
 
226
+ // command asks mbridge to do something. The sequence number makes it
227
+ // one-shot: the page repeats it until mbridge answers, and mbridge
228
+ // runs it once.
229
+ async function command(id) {
230
+ state.cmdSeq += 1;
231
+ const cmd = { id: id, seq: state.cmdSeq };
232
+ try {
233
+ const o = (await magpie.options.get()) || {};
234
+ o.cmd = cmd;
235
+ await magpie.options.set(o);
236
+ } catch (e) {
237
+ // mbridge will not hear it; the page says so
238
+ return false;
239
+ }
240
+ return true;
241
+ }
242
+
111
243
  // magpie takes one options write a second: a quick second click is
112
244
  // saved with the next one
113
245
  let saving = null;
@@ -116,6 +248,8 @@
116
248
  saving = setTimeout(async () => {
117
249
  saving = null;
118
250
  try {
251
+ // the whole object is replaced: mbridge's key goes with it, and
252
+ // mbridge puts its snapshot back on its next round
119
253
  opts = (await magpie.options.set({ ...opts, period: state.period, sort: state.sort })) || opts;
120
254
  } catch {
121
255
  // not remembering is not worth an error on the page
@@ -147,7 +281,34 @@
147
281
  b.textContent = V.sortLabel(b.dataset.sort, lang);
148
282
  }
149
283
  $("addr").textContent = V.STATUS_URL;
284
+ $("mbridge-ask").textContent = V.text(lang).mbridgeAsk;
285
+ $("mbridge-restart").textContent = V.text(lang).mbridgeRestart;
286
+ $("mbridge-ask").addEventListener("click", async () => {
287
+ const b = $("mbridge-ask");
288
+ const t = V.text(lang);
289
+ b.disabled = true;
290
+ b.textContent = t.mbridgeAsking;
291
+ const ok = await command("refresh");
292
+ if (!ok) { b.disabled = false; b.textContent = t.mbridgeAsk; }
293
+ // mbridge publishes and the next poll picks it up
294
+ setTimeout(() => { b.disabled = false; b.textContent = t.mbridgeAsk; load(); }, 3000);
295
+ });
296
+ $("mbridge-restart").addEventListener("click", async () => {
297
+ const b = $("mbridge-restart");
298
+ const t = V.text(lang);
299
+ b.disabled = true;
300
+ b.textContent = t.mbridgeRestarting;
301
+ const ok = await command("restart");
302
+ if (!ok) { b.disabled = false; b.textContent = t.mbridgeRestart; return; }
303
+ b.textContent = t.mbridgeRestartAsked;
304
+ });
150
305
  words();
151
306
  await load();
152
- setInterval(load, 60000);
307
+ // magpie's ledger moves slowly; mbridge's snapshot moves every fifteen
308
+ // seconds, and reading the options is free
309
+ setInterval(loadUsage, 60000);
310
+ setInterval(async () => {
311
+ await readOptions();
312
+ paintMbridge();
313
+ }, 5000);
153
314
  })();
package/page/index.html CHANGED
@@ -47,6 +47,32 @@
47
47
  <p class="muted empty" id="empty" hidden></p>
48
48
  </section>
49
49
 
50
+ <section class="mbridge">
51
+ <div class="mbridge-head">
52
+ <h2 data-t="mbridge">mbridge</h2>
53
+ <span id="mbridge-age" class="muted"></span>
54
+ </div>
55
+ <div id="mbridge-stats" class="stats"></div>
56
+ <h3 data-t="mbridgeLive">sessions running now</h3>
57
+ <div id="mbridge-live" class="cards"></div>
58
+ <h3 data-t="mbridgeDecisions">recent decisions</h3>
59
+ <table class="table mbridge-table">
60
+ <thead>
61
+ <tr>
62
+ <th></th><th data-t="harness">harness</th><th data-t="agent">agent</th>
63
+ <th data-t="tier">tier</th><th data-t="advised">advised</th>
64
+ <th data-t="reason">reason</th><th class="num" data-t="conf">confidence</th>
65
+ </tr>
66
+ </thead>
67
+ <tbody id="mbridge-decisions"></tbody>
68
+ </table>
69
+ <p id="mbridge-empty" class="muted"></p>
70
+ <div class="mbridge-actions">
71
+ <button type="button" id="mbridge-ask" class="btn"></button>
72
+ <button type="button" id="mbridge-restart" class="btn"></button>
73
+ </div>
74
+ </section>
75
+
50
76
  <section class="notes">
51
77
  <p class="note" data-t="disclaimer"></p>
52
78
  <p class="note">
package/page/style.css CHANGED
@@ -46,3 +46,22 @@ h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
46
46
  .addr { user-select: all; color: var(--magpie-fg, inherit); }
47
47
  .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--magpie-fg, inherit); }
48
48
  .error { color: var(--magpie-danger, #b42318); margin: 8px 0 0; }
49
+
50
+ /* the mbridge section: what mbridge published into the options */
51
+ .mbridge { margin-top: 16px; padding-top: 10px; border-top: 1px solid var(--magpie-border, #e3e3e3); }
52
+ .mbridge-head { display: flex; justify-content: space-between; align-items: baseline; }
53
+ .mbridge-head h2 { margin: 0 0 4px; font-size: 1em; font-weight: 600; }
54
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 6px 0 10px; }
55
+ .stat { padding: 5px 7px; border: 1px solid var(--magpie-border, #e3e3e3); border-radius: 6px; }
56
+ .stat b { display: block; font-size: 1.1em; font-variant-numeric: tabular-nums; }
57
+ .stat span { color: var(--magpie-fg-muted, #6b6b6b); font-size: .85em; }
58
+ .cards { display: grid; gap: 5px; margin-bottom: 10px; }
59
+ .card { padding: 5px 7px; border: 1px solid var(--magpie-border, #e3e3e3); border-radius: 6px; }
60
+ .card .row1 { display: flex; justify-content: space-between; gap: .5rem; }
61
+ .card .row2 { color: var(--magpie-fg-muted, #6b6b6b); font-size: .85em; }
62
+ .mbridge-table td { padding: 2px 4px; }
63
+ .mbridge-table td.differs { font-weight: 600; }
64
+ .mbridge-actions { display: flex; gap: 6px; margin-top: 8px; }
65
+ .btn { font: inherit; padding: 2px 9px; border-radius: 5px; cursor: pointer;
66
+ border: 1px solid var(--magpie-border, #d8d8d8); background: var(--magpie-bg-subtle, transparent); color: inherit; }
67
+ .btn[disabled] { opacity: .6; cursor: default; }
package/page/view.js CHANGED
@@ -33,6 +33,32 @@
33
33
  "This is everything magpie recorded. It is mbridge's usage only when every client goes through mbridge; whatever you use magpie for directly is in here too.",
34
34
  status: "mbridge's own status:",
35
35
  restart: "restart it with",
36
+ mbridge: "mbridge",
37
+ mbridgeNone: "mbridge has not published anything yet",
38
+ mbridgeAge: "published",
39
+ mbridgeLive: "sessions running now",
40
+ mbridgeNoLive: "no session is going through mbridge right now",
41
+ mbridgeDecisions: "recent decisions",
42
+ mbridgeWindow: "the last",
43
+ mbridgeTurns: "turns",
44
+ mbridgeCoverage: "asked the classifier",
45
+ mbridgeFailures: "failures",
46
+ mbridgeAsk: "ask for fresh numbers",
47
+ mbridgeAsking: "asking…",
48
+ mbridgeRestart: "restart mbridge",
49
+ mbridgeRestarting: "restarting…",
50
+ mbridgeRestartAsked: "restart asked for",
51
+ mbridgeRestartRefused: "refused",
52
+ tier: "tier",
53
+ advised: "advised",
54
+ reason: "reason",
55
+ conf: "confidence",
56
+ unknown: "unknown",
57
+ running: "running",
58
+ notRunning: "installed, not running",
59
+ notInstalled: "not installed",
60
+ tools: "tools",
61
+ noDecisions: "no decision yet",
36
62
  },
37
63
  zh: {
38
64
  title: "mbridge",
@@ -58,6 +84,32 @@
58
84
  "这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
59
85
  status: "mbridge 自己的状态:",
60
86
  restart: "重启用",
87
+ mbridge: "mbridge",
88
+ mbridgeNone: "mbridge 还没有发布过任何东西",
89
+ mbridgeAge: "发布于",
90
+ mbridgeLive: "正在选档的会话",
91
+ mbridgeNoLive: "现在没有会话在走 mbridge",
92
+ mbridgeDecisions: "最近判档",
93
+ mbridgeWindow: "最近",
94
+ mbridgeTurns: "轮",
95
+ mbridgeCoverage: "问了分类器",
96
+ mbridgeFailures: "失败",
97
+ mbridgeAsk: "要最新数字",
98
+ mbridgeAsking: "正在要…",
99
+ mbridgeRestart: "重启 mbridge",
100
+ mbridgeRestarting: "正在重启…",
101
+ mbridgeRestartAsked: "已发出重启指令",
102
+ mbridgeRestartRefused: "被拒绝",
103
+ tier: "档",
104
+ advised: "建议",
105
+ reason: "reason",
106
+ conf: "置信度",
107
+ unknown: "未知",
108
+ running: "在跑",
109
+ notRunning: "装了,没在跑",
110
+ notInstalled: "没装",
111
+ tools: "工具",
112
+ noDecisions: "还没有判档",
61
113
  },
62
114
  };
63
115
 
@@ -155,6 +207,73 @@
155
207
  return t["sort" + sort.charAt(0).toUpperCase() + sort.slice(1)] || sort;
156
208
  }
157
209
 
210
+ // mbridgeView is what the page shows of mbridge, from the snapshot
211
+ // mbridge published into the plugin's options (SP14 §7).
212
+ function mbridgeView(snapshot, lang) {
213
+ const t = text(lang);
214
+ if (!snapshot) {
215
+ return { none: true, text: t.mbridgeNone };
216
+ }
217
+ const s = snapshot;
218
+ const cov = s.window && s.window.coverage ? s.window.coverage : { asked: 0, total: 0 };
219
+ const tiers = (s.window && s.window.tiers) || {};
220
+ const reasons = (s.window && s.window.reasons) || {};
221
+ const turnCount = Object.keys(tiers).reduce((a, k) => a + tiers[k], 0);
222
+ const topReason = Object.keys(reasons).sort((a, b) => reasons[b] - reasons[a])[0] || "";
223
+ const live = s.live || [];
224
+ // the decisions this process made are in memory (a restart empties
225
+ // them): the window's turns are the fallback
226
+ const decisions = (s.decisions && s.decisions.length)
227
+ ? s.decisions
228
+ : ((s.window && s.window.turns) || []);
229
+ const at = s.at ? new Date(s.at) : null;
230
+ const age = at && !isNaN(at)
231
+ ? Math.max(0, Math.round((Date.now() - at.getTime()) / 1000))
232
+ : null;
233
+ return {
234
+ none: false,
235
+ version: s.version || "",
236
+ pid: s.pid || 0,
237
+ uptime: s.uptime_s || 0,
238
+ listen: s.listen || "",
239
+ service: (s.service && s.service.running) ? t.running : ((s.service && s.service.installed) ? t.notRunning : t.notInstalled),
240
+ magpie: s.magpie_ok ? (s.magpie_version || "") : "—",
241
+ turnCount: turnCount,
242
+ coverage: cov.total ? Math.round((100 * cov.asked) / cov.total) + "%" : "—",
243
+ failures: (s.window && s.window.classify && s.window.classify.failures) || 0,
244
+ topReason: topReason,
245
+ age: age,
246
+ ageText: age === null ? "" : (age < 60 ? age + "s" : (age < 3600 ? Math.round(age / 60) + "m" : Math.round(age / 3600) + "h")),
247
+ live: live.map((c) => ({
248
+ session: (c.session || "").slice(0, 8),
249
+ harness: c.harness || t.unknown,
250
+ agent: c.agent || "",
251
+ tier: c.tier || "",
252
+ group: c.group || "",
253
+ reason: c.reason || "",
254
+ conf: c.tier_confidence,
255
+ turns: c.turns || 0,
256
+ tools: c.agent === "gateway" ? (c.tools || 0) + "/" + (c.tool_failures || 0) : null,
257
+ last: c.last_at || "",
258
+ })),
259
+ decisions: decisions.slice(0, 12).map((ev) => ({
260
+ time: ev.t || "",
261
+ clock: ev.t ? ev.t.slice(11, 19) : "",
262
+ harness: ev.harness || t.unknown,
263
+ agent: ev.agent || "",
264
+ tier: ev.tier || "",
265
+ advised: ev.classified_tier || "",
266
+ differs: !!(ev.classified_tier && ev.classified_tier !== ev.tier),
267
+ reason: ev.reason || "",
268
+ source: ev.classifier || ev.source || "",
269
+ conf: ev.tier_confidence,
270
+ ms: ev.latency_ms || 0,
271
+ })),
272
+ emptyLive: live.length === 0,
273
+ noDecisions: decisions.length === 0,
274
+ };
275
+ }
276
+
158
277
  globalThis.MbridgeView = {
159
278
  TEXT,
160
279
  PERIODS,
@@ -169,5 +288,6 @@
169
288
  since,
170
289
  periodLabel,
171
290
  sortLabel,
291
+ mbridgeView,
172
292
  };
173
293
  })();