@weiping/mbridge-page 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weiping/mbridge-page",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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,7 +191,21 @@
98
191
  }
99
192
  }
100
193
 
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
+ if (V.PERIODS.includes(o.period)) state.period = o.period;
200
+ if (V.SORTS.includes(o.sort)) state.sort = o.sort;
201
+ state.mbridge = o.mbridge || null;
202
+ } catch {
203
+ // the options are not readable: keep what we have
204
+ }
205
+ }
206
+
101
207
  async function load() {
208
+ await readOptions();
102
209
  try {
103
210
  state.summary = await magpie.read("usage:summary", { period: state.period });
104
211
  state.error = null;
@@ -108,6 +215,23 @@
108
215
  paint();
109
216
  }
110
217
 
218
+ // command asks mbridge to do something. The sequence number makes it
219
+ // one-shot: the page repeats it until mbridge answers, and mbridge
220
+ // runs it once.
221
+ async function command(id) {
222
+ state.cmdSeq += 1;
223
+ const cmd = { id: id, seq: state.cmdSeq };
224
+ try {
225
+ const o = (await magpie.options.get()) || {};
226
+ o.cmd = cmd;
227
+ await magpie.options.set(o);
228
+ } catch (e) {
229
+ // mbridge will not hear it; the page says so
230
+ return false;
231
+ }
232
+ return true;
233
+ }
234
+
111
235
  // magpie takes one options write a second: a quick second click is
112
236
  // saved with the next one
113
237
  let saving = null;
@@ -116,6 +240,8 @@
116
240
  saving = setTimeout(async () => {
117
241
  saving = null;
118
242
  try {
243
+ // the whole object is replaced: mbridge's key goes with it, and
244
+ // mbridge puts its snapshot back on its next round
119
245
  opts = (await magpie.options.set({ ...opts, period: state.period, sort: state.sort })) || opts;
120
246
  } catch {
121
247
  // not remembering is not worth an error on the page
@@ -147,6 +273,27 @@
147
273
  b.textContent = V.sortLabel(b.dataset.sort, lang);
148
274
  }
149
275
  $("addr").textContent = V.STATUS_URL;
276
+ $("mbridge-ask").textContent = V.text(lang).mbridgeAsk;
277
+ $("mbridge-restart").textContent = V.text(lang).mbridgeRestart;
278
+ $("mbridge-ask").addEventListener("click", async () => {
279
+ const b = $("mbridge-ask");
280
+ const t = V.text(lang);
281
+ b.disabled = true;
282
+ b.textContent = t.mbridgeAsking;
283
+ const ok = await command("refresh");
284
+ if (!ok) { b.disabled = false; b.textContent = t.mbridgeAsk; }
285
+ // mbridge publishes and the next poll picks it up
286
+ setTimeout(() => { b.disabled = false; b.textContent = t.mbridgeAsk; load(); }, 3000);
287
+ });
288
+ $("mbridge-restart").addEventListener("click", async () => {
289
+ const b = $("mbridge-restart");
290
+ const t = V.text(lang);
291
+ b.disabled = true;
292
+ b.textContent = t.mbridgeRestarting;
293
+ const ok = await command("restart");
294
+ if (!ok) { b.disabled = false; b.textContent = t.mbridgeRestart; return; }
295
+ b.textContent = t.mbridgeRestartAsked;
296
+ });
150
297
  words();
151
298
  await load();
152
299
  setInterval(load, 60000);
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
  })();