@weiping/mbridge-page 0.1.0 → 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.0",
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": [
@@ -33,6 +33,12 @@
33
33
  "file": "page/index.html",
34
34
  "icon": "page/icon.svg"
35
35
  },
36
- "permissions": ["usage:summary"]
36
+ "permissions": [
37
+ "usage:summary"
38
+ ],
39
+ "options": {
40
+ "period": "7d",
41
+ "sort": "tokens"
42
+ }
37
43
  }
38
44
  }
package/page/app.js CHANGED
@@ -1,161 +1,300 @@
1
- /* app.js reads magpie's ledger and draws it. It never reaches mbridge:
2
- the sandbox a magpie page runs in has no network at all, and the only
3
- thing this page needs is what magpie already recorded (SP14 §2). */
4
-
5
- import { tiles, rows, periods, sorts, header } from "./view.js";
1
+ // app.js reads magpie's ledger and draws it. It never reaches mbridge:
2
+ // the sandbox a magpie page runs in has no network at all (connect-src
3
+ // 'none'), and the only thing this page needs is what magpie already
4
+ // recorded (SP14 §2).
5
+ //
6
+ // A classic script, like the community's usage page: it reads the view
7
+ // from globalThis.
8
+ (async () => {
9
+ const V = globalThis.MbridgeView;
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
+ }
26
+ const theme = await magpie.ready;
27
+ let lang = theme.lang;
6
28
 
7
- const REFRESH_MS = 60000; // magpie's ledger moves slowly enough
8
- const STATUS_URL = "http://127.0.0.1:3426/status";
9
- const OPTIONS_KEY = "mbridge-page"; // magpie options keep the period and the sort
29
+ // the period and the sort live in this plugin's own options, so the
30
+ // page opens where it was left
31
+ let opts = {};
32
+ try {
33
+ opts = (await magpie.options.get()) || {};
34
+ } catch {
35
+ // no options yet: the defaults in package.json stand
36
+ }
37
+ const state = {
38
+ period: V.PERIODS.includes(opts.period) ? opts.period : "7d",
39
+ sort: V.SORTS.includes(opts.sort) ? opts.sort : "tokens",
40
+ summary: null,
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,
46
+ };
10
47
 
11
- const state = { period: "7d", sort: "tokens", summary: null, error: null, lang: "en" };
48
+ function words() {
49
+ const t = V.text(lang);
50
+ document.querySelectorAll("[data-t]").forEach((e) => {
51
+ e.textContent = t[e.dataset.t];
52
+ });
53
+ $("status").textContent = t.status;
54
+ $("restart").textContent = t.restart;
55
+ $("cmd").textContent = "mbridge service restart";
56
+ }
12
57
 
13
- function $(id) {
14
- return document.getElementById(id);
15
- }
58
+ function pick(group, attr, value) {
59
+ $(group).querySelectorAll("button").forEach((b) => {
60
+ b.setAttribute("aria-selected", String(b.dataset[attr] === value));
61
+ });
62
+ }
16
63
 
17
- function segment(container, items, current, onPick) {
18
- container.textContent = "";
19
- for (const item of items) {
20
- const b = document.createElement("button");
21
- b.type = "button";
22
- b.className = "seg" + (item.key === current ? " seg-on" : "");
23
- b.textContent = item.label;
24
- b.addEventListener("click", () => onPick(item.key));
25
- container.appendChild(b);
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
+ }
26
134
  }
27
- }
28
135
 
29
- function render() {
30
- const t = header(state.summary, state.lang);
31
- $("title").textContent = t.title;
32
- $("subtitle").textContent = state.summary ? t.subtitle : "";
33
- if (t.since) $("subtitle").textContent += " · " + t.since;
136
+ function paint() {
137
+ pick("periods", "period", state.period);
138
+ pick("sorts", "sort", state.sort);
139
+ // mbridge's part does not depend on magpie's usage
140
+ paintMbridge();
141
+ const s = state.summary;
142
+ if (!s) return;
143
+ $("since").textContent = V.since(s, lang);
34
144
 
35
- segment($("periods"), periods(state.lang), state.period, (k) => {
36
- if (k === state.period) return;
37
- state.period = k;
38
- save();
39
- load();
40
- });
41
- segment($("sorts"), sorts(state.lang), state.sort, (k) => {
42
- state.sort = k;
43
- save();
44
- render();
45
- });
145
+ $("tiles").replaceChildren(
146
+ ...V.tiles(s, lang).map((tile) => {
147
+ const cell = document.createElement("div");
148
+ cell.className = "tile";
149
+ for (const [cls, value] of [["k", tile.label], ["v", tile.value], ["s", tile.sub]]) {
150
+ if (value === undefined) continue;
151
+ const e = document.createElement("div");
152
+ e.className = cls;
153
+ e.textContent = value;
154
+ cell.append(e);
155
+ }
156
+ return cell;
157
+ })
158
+ );
46
159
 
47
- const box = $("tiles");
48
- box.textContent = "";
49
- for (const tile of tiles(state.summary, state.lang)) {
50
- const cell = document.createElement("div");
51
- cell.className = "tile";
52
- const label = document.createElement("div");
53
- label.className = "tile-label";
54
- label.textContent = tile.label;
55
- const value = document.createElement("div");
56
- value.className = "tile-value";
57
- value.textContent = tile.value;
58
- cell.appendChild(label);
59
- cell.appendChild(value);
60
- if (tile.sub) {
61
- const sub = document.createElement("div");
62
- sub.className = "tile-sub";
63
- sub.textContent = tile.sub;
64
- cell.appendChild(sub);
160
+ const rows = V.rows(s, state.sort, lang);
161
+ $("rows").replaceChildren(
162
+ ...rows.map((r) => {
163
+ const tr = document.createElement("tr");
164
+ const name = document.createElement("td");
165
+ name.className = "model";
166
+ name.textContent = r.model;
167
+ const bar = document.createElement("span");
168
+ bar.className = "bar";
169
+ const fill = document.createElement("i");
170
+ fill.style.width = Math.max(2, Math.round(r.shareValue * 100)) + "%";
171
+ bar.append(fill);
172
+ name.append(bar);
173
+ const calls = document.createElement("td");
174
+ calls.className = "num";
175
+ calls.textContent = r.calls;
176
+ const tokens = document.createElement("td");
177
+ tokens.className = "num";
178
+ tokens.textContent = r.tokens;
179
+ const cost = document.createElement("td");
180
+ cost.className = "num";
181
+ cost.textContent = r.cost;
182
+ tr.append(name, calls, tokens, cost);
183
+ return tr;
184
+ })
185
+ );
186
+ $("empty").hidden = rows.length > 0;
187
+ $("empty").textContent = V.text(lang).empty;
188
+ $("error").hidden = !state.error;
189
+ if (state.error) {
190
+ $("error").textContent = V.text(lang).failed + ": " + state.error + " · " + V.text(lang).retry;
65
191
  }
66
- box.appendChild(cell);
67
192
  }
68
193
 
69
- const r = rows(state.summary, state.sort, state.lang);
70
- $("models-label").textContent = r.label;
71
- $("models-sort").textContent = r.sortLabel;
72
- $("th-model").textContent = viewText("model");
73
- $("th-calls").textContent = viewText("calls");
74
- $("th-tokens").textContent = viewText("tokens");
75
- $("th-cost").textContent = viewText("cost");
76
-
77
- const body = $("rows");
78
- body.textContent = "";
79
- for (const item of r.items) {
80
- const tr = document.createElement("tr");
81
- const name = document.createElement("td");
82
- name.className = "model";
83
- name.textContent = item.model;
84
- const bar = document.createElement("span");
85
- bar.className = "bar";
86
- const fill = document.createElement("i");
87
- fill.style.width = Math.round(item.shareValue * 100) + "%";
88
- bar.appendChild(fill);
89
- name.appendChild(bar);
90
- tr.appendChild(name);
91
- for (const key of ["calls", "tokens", "cost"]) {
92
- const td = document.createElement("td");
93
- td.className = "num";
94
- td.textContent = item[key];
95
- tr.appendChild(td);
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
96
204
  }
97
- body.appendChild(tr);
98
205
  }
99
- $("empty").textContent = r.items.length ? "" : t.emptyText;
100
-
101
- $("disclaimer").textContent = t.disclaimer;
102
- $("status-label").textContent = t.status + " ";
103
- // the address is selectable text, not a link: the sandbox can navigate
104
- // itself, but that is a one-way ticket (SP14 §2)
105
- $("status-addr").textContent = t.statusWhere + " " + STATUS_URL;
106
- $("restart-label").textContent = " · " + t.restart + " ";
107
- $("error").textContent = state.error ? state.error + " · " + t.retry : "";
108
- }
109
206
 
110
- // viewText is one word from the page's vocabulary.
111
- function viewText(key) {
112
- const v = globalThis.MbridgeView;
113
- const l = state.lang === "zh" ? "zh" : "en";
114
- return v.TEXT[l][key] || key;
115
- }
116
-
117
- async function load() {
118
- try {
119
- state.summary = await magpie.read("usage:summary", { period: state.period });
120
- state.error = null;
121
- } catch (err) {
122
- state.error = (err && err.message) || String(err);
207
+ async function load() {
208
+ await readOptions();
209
+ try {
210
+ state.summary = await magpie.read("usage:summary", { period: state.period });
211
+ state.error = null;
212
+ } catch (e) {
213
+ state.error = (e && e.message) || String(e);
214
+ }
215
+ paint();
123
216
  }
124
- render();
125
- }
126
217
 
127
- // save remembers the period and the sort in magpie's own options, so the
128
- // page comes back the way it was left. magpie takes one write a second.
129
- async function save() {
130
- try {
131
- const opts = (await magpie.options.get()) || {};
132
- opts[OPTIONS_KEY] = { period: state.period, sort: state.sort };
133
- await magpie.options.set(opts);
134
- } catch {
135
- // not being able to remember is not worth an error on the page
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;
136
233
  }
137
- }
138
234
 
139
- async function restore() {
140
- try {
141
- const opts = (await magpie.options.get()) || {};
142
- const mine = opts[OPTIONS_KEY];
143
- if (mine && mine.period) state.period = mine.period;
144
- if (mine && mine.sort) state.sort = mine.sort;
145
- } catch {
146
- // the defaults stand
235
+ // magpie takes one options write a second: a quick second click is
236
+ // saved with the next one
237
+ let saving = null;
238
+ function save() {
239
+ if (saving) return;
240
+ saving = setTimeout(async () => {
241
+ saving = null;
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
245
+ opts = (await magpie.options.set({ ...opts, period: state.period, sort: state.sort })) || opts;
246
+ } catch {
247
+ // not remembering is not worth an error on the page
248
+ }
249
+ }, 1100);
147
250
  }
148
- }
149
251
 
150
- (async function start() {
151
- await magpie.ready;
152
- const theme = magpie.theme || {};
153
- if ((theme.lang || "").toLowerCase().startsWith("zh")) state.lang = "zh";
154
- if (magpie.onTheme) magpie.onTheme((t) => {
155
- if ((t && t.lang || "").toLowerCase().startsWith("zh")) state.lang = "zh";
156
- render();
252
+ $("periods").addEventListener("click", (e) => {
253
+ const b = e.target.closest("button[data-period]");
254
+ if (!b || b.dataset.period === state.period) return;
255
+ state.period = b.dataset.period;
256
+ save();
257
+ load();
258
+ });
259
+
260
+ $("sorts").addEventListener("click", (e) => {
261
+ const b = e.target.closest("button[data-sort]");
262
+ if (!b || b.dataset.sort === state.sort) return;
263
+ state.sort = b.dataset.sort;
264
+ save();
265
+ paint();
266
+ });
267
+
268
+ // the period and sort buttons carry their own words
269
+ for (const b of $("periods").querySelectorAll("button[data-period]")) {
270
+ b.textContent = V.periodLabel(b.dataset.period, lang);
271
+ }
272
+ for (const b of $("sorts").querySelectorAll("button[data-sort]")) {
273
+ b.textContent = V.sortLabel(b.dataset.sort, lang);
274
+ }
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;
157
296
  });
158
- await restore();
297
+ words();
159
298
  await load();
160
- setInterval(load, REFRESH_MS);
161
- })();
299
+ setInterval(load, 60000);
300
+ })();
package/page/index.html CHANGED
@@ -9,50 +9,82 @@
9
9
  <script src="view.js"></script>
10
10
  </head>
11
11
  <body>
12
- <main id="root" class="root">
12
+ <main class="root">
13
13
  <header class="head">
14
- <h1 id="title" class="title">mbridge</h1>
15
- <p id="subtitle" class="subtitle"></p>
14
+ <h1 data-t="title">mbridge</h1>
15
+ <p class="subtitle" data-t="subtitle"></p>
16
+ <p class="since" id="since"></p>
16
17
  </header>
17
18
 
18
19
  <div class="bar">
19
- <div id="periods" class="segmented"></div>
20
- <div id="sorts" class="segmented"></div>
20
+ <div class="segmented" id="periods">
21
+ <button type="button" data-period="today" aria-selected="false"></button>
22
+ <button type="button" data-period="7d" aria-selected="false"></button>
23
+ <button type="button" data-period="30d" aria-selected="false"></button>
24
+ </div>
25
+ <div class="segmented" id="sorts">
26
+ <button type="button" data-sort="tokens" aria-selected="false"></button>
27
+ <button type="button" data-sort="calls" aria-selected="false"></button>
28
+ <button type="button" data-sort="cost" aria-selected="false"></button>
29
+ </div>
21
30
  </div>
22
31
 
23
- <section id="tiles" class="tiles"></section>
32
+ <section class="tiles" id="tiles"></section>
24
33
 
25
34
  <section class="models">
26
- <div class="models-head">
27
- <h2 id="models-label" class="label"></h2>
28
- <span id="models-sort" class="muted"></span>
29
- </div>
35
+ <h2 data-t="models">by model</h2>
30
36
  <table class="table">
31
37
  <thead>
32
38
  <tr>
33
- <th id="th-model"></th>
34
- <th class="num" id="th-calls"></th>
35
- <th class="num" id="th-tokens"></th>
36
- <th class="num" id="th-cost"></th>
39
+ <th data-t="model">model</th>
40
+ <th class="num" data-t="calls">calls</th>
41
+ <th class="num" data-t="tokens">tokens</th>
42
+ <th class="num" data-t="cost">cost</th>
37
43
  </tr>
38
44
  </thead>
39
45
  <tbody id="rows"></tbody>
40
46
  </table>
41
- <p id="empty" class="muted empty"></p>
47
+ <p class="muted empty" id="empty" hidden></p>
48
+ </section>
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>
42
74
  </section>
43
75
 
44
76
  <section class="notes">
45
- <p id="disclaimer" class="note"></p>
77
+ <p class="note" data-t="disclaimer"></p>
46
78
  <p class="note">
47
- <span id="status-label"></span>
48
- <span id="status-addr" class="addr" title="select and copy it"></span>
49
- <span id="restart-label"></span>
50
- <code class="cmd">mbridge service restart</code>
79
+ <span id="status"></span>
80
+ <span class="addr" id="addr" title="select and copy it"></span>
81
+ <span id="restart"></span>
82
+ <code class="cmd" id="cmd"></code>
51
83
  </p>
52
84
  </section>
53
85
 
54
- <p id="error" class="error"></p>
86
+ <p class="error" id="error" hidden></p>
55
87
  </main>
56
88
  <script src="app.js" defer></script>
57
89
  </body>
58
- </html>
90
+ </html>
package/page/style.css CHANGED
@@ -1,5 +1,5 @@
1
- /* Only magpie's own variables, so the page follows the window's theme
2
- in both light and dark without knowing either. */
1
+ /* Only magpie's own variables, so the page follows the window's theme in
2
+ both light and dark without knowing either. */
3
3
  .root {
4
4
  --pad: 12px;
5
5
  padding: var(--pad);
@@ -7,30 +7,31 @@
7
7
  color: var(--magpie-fg, #1a1a1a);
8
8
  background: var(--magpie-bg, transparent);
9
9
  }
10
- .title { margin: 0; font-size: 1.05em; font-weight: 600; }
11
- .subtitle { margin: 2px 0 10px; color: var(--magpie-fg-muted, #6b6b6b); }
10
+ h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
11
+ .subtitle { margin: 2px 0 0; color: var(--magpie-fg-muted, #6b6b6b); }
12
+ .since { margin: 0 0 8px; color: var(--magpie-fg-muted, #6b6b6b); font-size: .9em; }
12
13
  .bar { display: flex; gap: 8px; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
13
14
  .segmented { display: flex; gap: 2px; }
14
- .seg {
15
- font: inherit; color: inherit; cursor: pointer;
16
- padding: 2px 8px; border-radius: 5px;
15
+ .segmented button {
16
+ font: inherit; color: inherit; cursor: pointer; padding: 2px 8px; border-radius: 5px;
17
17
  border: 1px solid var(--magpie-border, #d8d8d8);
18
18
  background: var(--magpie-bg-subtle, transparent);
19
19
  }
20
- .seg-on { background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff; }
20
+ .segmented button[aria-selected="true"] {
21
+ background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff;
22
+ }
21
23
  .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; }
22
24
  .tile {
23
25
  padding: 6px 8px; border-radius: 6px;
24
26
  border: 1px solid var(--magpie-border, #e3e3e3);
25
27
  background: var(--magpie-bg-subtle, transparent);
26
28
  }
27
- .tile-label { color: var(--magpie-fg-muted, #6b6b6b); }
28
- .tile-value { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
29
- .tile-sub { color: var(--magpie-fg-muted, #6b6b6b); }
30
- .label { margin: 0; font-size: 1em; font-weight: 600; }
31
- .models-head { display: flex; justify-content: space-between; align-items: baseline; }
29
+ .tile .k { color: var(--magpie-fg-muted, #6b6b6b); }
30
+ .tile .v { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
31
+ .tile .s { color: var(--magpie-fg-muted, #6b6b6b); }
32
+ .models h2 { margin: 0 0 4px; font-size: 1em; font-weight: 600; }
32
33
  .muted { color: var(--magpie-fg-muted, #6b6b6b); }
33
- .table { width: 100%; border-collapse: collapse; margin-top: 4px; }
34
+ .table { width: 100%; border-collapse: collapse; }
34
35
  .table th, .table td { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--magpie-border, #e3e3e3); }
35
36
  .table .num { text-align: right; font-variant-numeric: tabular-nums; }
36
37
  .table th.num { text-align: right; color: var(--magpie-fg-muted, #6b6b6b); font-weight: 500; }
@@ -40,7 +41,27 @@
40
41
  .empty { margin: 6px 0; }
41
42
  .notes { margin-top: 14px; }
42
43
  .note { margin: 4px 0; color: var(--magpie-fg-muted, #6b6b6b); }
43
- /* the address is selectable text, not a link */
44
+ /* the address is selectable text, not a link: the sandbox may navigate
45
+ itself, but that is a one-way ticket (SP14 §2) */
44
46
  .addr { user-select: all; color: var(--magpie-fg, inherit); }
45
47
  .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--magpie-fg, inherit); }
46
- .error { color: var(--magpie-danger, #b42318); min-height: 1em; margin: 8px 0 0; }
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
@@ -1,178 +1,293 @@
1
- // view.js is mbridge's magpie page: the words and the arithmetic, with
2
- // no DOM in it, so the page and the tests can both use it.
1
+ // view.js is what the page shows, worked out from usage:summary without
2
+ // the DOM, so a test can check it.
3
3
  //
4
- // The numbers come from magpie's own ledger (`magpie.read("usage:summary")`),
5
- // never from mbridge: the page runs in a sandbox that cannot reach mbridge
6
- // at all (SP14 §2). Nothing here knows about tiers — magpie records the
7
- // model that answered, and mbridge's group is not in there.
4
+ // It is a classic script, not a module: a magpie page runs in a sandbox
5
+ // whose CSP serves the plugin's own files, and the community's usage
6
+ // page does it this way too (globalThis.UsageView). A module script
7
+ // would be a different thing to load, and the test below loads this file
8
+ // the same way the page does.
8
9
 
9
- const TEXT = {
10
- en: {
11
- title: "mbridge",
12
- subtitle: "what magpie's ledger recorded",
13
- calls: "calls",
14
- errors: "errors",
15
- errorShare: "of the calls",
16
- tokens: "tokens",
17
- cost: "cost",
18
- models: "by model",
19
- model: "model",
20
- period: { today: "today", "7d": "7 days", "30d": "30 days" },
21
- sort: { tokens: "tokens", calls: "calls", cost: "cost" },
22
- empty: "no calls in this period",
23
- error: "magpie's usage could not be read",
24
- retry: "try again in a minute",
25
- since: "since",
26
- total: "total",
27
- disclaimer:
28
- "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.",
29
- status: "mbridge's own status",
30
- statusWhere: "mbridge's status page:",
31
- restart: "restart it with",
32
- },
33
- zh: {
34
- title: "mbridge",
35
- subtitle: "magpie 记下的全部用量",
36
- calls: "调用",
37
- errors: "错误",
38
- errorShare: "占调用",
39
- tokens: "Token",
40
- cost: "费用",
41
- models: "按模型",
42
- model: "模型",
43
- period: { today: "今天", "7d": "7 天", "30d": "30 天" },
44
- sort: { tokens: "Token", calls: "调用", cost: "费用" },
45
- empty: "这个区间没有调用",
46
- error: "读不到 magpie 的用量",
47
- retry: "一分钟后自动重试",
48
- since: "起",
49
- total: "合计",
50
- disclaimer:
51
- "这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
52
- status: "mbridge 自己的状态",
53
- statusWhere: "mbridge 的状态页:",
54
- restart: "重启用",
55
- },
56
- };
10
+ (() => {
11
+ const TEXT = {
12
+ en: {
13
+ title: "mbridge",
14
+ subtitle: "what magpie's ledger recorded",
15
+ calls: "calls",
16
+ errors: "errors",
17
+ errorShare: "of the calls",
18
+ tokens: "tokens",
19
+ cost: "cost",
20
+ models: "by model",
21
+ model: "model",
22
+ today: "today",
23
+ "7d": "7 days",
24
+ "30d": "30 days",
25
+ sortTokens: "tokens",
26
+ sortCalls: "calls",
27
+ sortCost: "cost",
28
+ empty: "no calls in this period",
29
+ failed: "couldn't read usage",
30
+ retry: "try again in a minute",
31
+ since: "since",
32
+ disclaimer:
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
+ status: "mbridge's own status:",
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",
62
+ },
63
+ zh: {
64
+ title: "mbridge",
65
+ subtitle: "magpie 记下的全部用量",
66
+ calls: "调用",
67
+ errors: "错误",
68
+ errorShare: "占调用",
69
+ tokens: "Token",
70
+ cost: "费用",
71
+ models: "按模型",
72
+ model: "模型",
73
+ today: "今天",
74
+ "7d": "7 天",
75
+ "30d": "30 天",
76
+ sortTokens: "Token",
77
+ sortCalls: "调用",
78
+ sortCost: "费用",
79
+ empty: "这个区间没有调用",
80
+ failed: "读不到 magpie 的用量",
81
+ retry: "一分钟后自动重试",
82
+ since: "起",
83
+ disclaimer:
84
+ "这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
85
+ status: "mbridge 自己的状态:",
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: "还没有判档",
113
+ },
114
+ };
57
115
 
58
- /** count is a number a person reads: 1,234 or 1.23M. */
59
- export function count(n) {
60
- const v = Number(n) || 0;
61
- if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(2) + "B";
62
- if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(2) + "M";
63
- if (Math.abs(v) >= 1e3) return v.toLocaleString("en-US");
64
- return String(v);
65
- }
116
+ const PERIODS = ["today", "7d", "30d"];
117
+ const SORTS = ["tokens", "calls", "cost"];
118
+ const STATUS_URL = "http://127.0.0.1:3426/status";
66
119
 
67
- /** money is a price, never NaN, and never "$0.00" for something real. */
68
- export function money(n) {
69
- const v = Number(n);
70
- if (!isFinite(v)) return "$0";
71
- if (v === 0) return "$0";
72
- if (v > 0 && v < 0.01) return "<$0.01";
73
- if (v < 10) return "$" + v.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
74
- if (v < 1000) return "$" + v.toFixed(2);
75
- return "$" + v.toFixed(0);
76
- }
120
+ // text is the words for a language: zh for anything that starts with zh.
121
+ function text(lang) {
122
+ return String(lang || "").toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
123
+ }
77
124
 
78
- /** share is a percentage, and never NaN. */
79
- export function pct(part, whole) {
80
- const p = Number(part) || 0;
81
- const w = Number(whole) || 0;
82
- if (w <= 0) return "0%";
83
- const v = (100 * p) / w;
84
- if (!isFinite(v)) return "0%";
85
- return (Math.round(v * 10) / 10).toString() + "%";
86
- }
125
+ // count is a number a person reads: 1,234 or 1.23M.
126
+ function count(n) {
127
+ n = Number(n) || 0;
128
+ const a = Math.abs(n);
129
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
130
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";
131
+ if (a >= 1e3) return n.toLocaleString("en-US");
132
+ return String(n);
133
+ }
87
134
 
88
- function lang(l) {
89
- return l === "zh" ? "zh" : "en";
90
- }
135
+ // money is a price, never NaN, and never "$0.00" for something real.
136
+ function money(n) {
137
+ n = Number(n);
138
+ if (!isFinite(n)) return "$0";
139
+ if (n === 0) return "$0";
140
+ if (n > 0 && n < 0.01) return "<$0.01";
141
+ if (n < 10) return "$" + n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
142
+ if (n < 1000) return "$" + n.toFixed(2);
143
+ return "$" + n.toFixed(0);
144
+ }
91
145
 
92
- /** tiles are the four numbers at the top: calls, errors, tokens, cost. */
93
- export function tiles(summary, l = "en") {
94
- const t = TEXT[lang(l)];
95
- const s = summary || {};
96
- return [
97
- { key: "calls", label: t.calls, value: count(s.calls) },
98
- {
99
- key: "errors",
100
- label: t.errors,
101
- value: count(s.errors),
102
- sub: t.errorShare + " " + pct(s.errors, s.calls),
103
- },
104
- { key: "tokens", label: t.tokens, value: count((s.input || 0) + (s.output || 0)) },
105
- { key: "cost", label: t.cost, value: money(s.cost) },
106
- ];
107
- }
146
+ // share is a percentage, and never NaN.
147
+ function pct(part, whole) {
148
+ const p = Number(part) || 0;
149
+ const w = Number(whole) || 0;
150
+ if (w <= 0) return "0%";
151
+ const v = (100 * p) / w;
152
+ if (!isFinite(v)) return "0%";
153
+ return (Math.round(v * 10) / 10).toString() + "%";
154
+ }
155
+
156
+ // tiles are the four numbers at the top: calls, errors, tokens, cost.
157
+ function tiles(summary, lang) {
158
+ const t = text(lang);
159
+ const s = summary || {};
160
+ return [
161
+ { key: "calls", label: t.calls, value: count(s.calls) },
162
+ {
163
+ key: "errors",
164
+ label: t.errors,
165
+ value: count(s.errors),
166
+ sub: t.errorShare + " " + pct(s.errors, s.calls),
167
+ },
168
+ { key: "tokens", label: t.tokens, value: count((s.input || 0) + (s.output || 0)) },
169
+ { key: "cost", label: t.cost, value: money(s.cost) },
170
+ ];
171
+ }
108
172
 
109
- /** rows are the model table, sorted by tokens, calls or cost, with each
110
- * model's share of the total. */
111
- export function rows(summary, sort = "tokens", l = "en") {
112
- const t = TEXT[lang(l)];
113
- const s = summary || {};
114
- const models = Array.isArray(s.models) ? s.models : [];
115
- const key = sort === "calls" ? "calls" : sort === "cost" ? "cost" : "tokens";
116
- const value = (m) =>
117
- key === "tokens" ? (m.input || 0) + (m.output || 0) : m[key] || 0;
118
- const sorted = models.slice().sort((a, b) => value(b) - value(a));
119
- const total = sorted.reduce((a, m) => a + value(m), 0);
120
- const labels = t.sort;
121
- return {
122
- sort: key,
123
- label: t.models,
124
- sortLabel: labels[key] || key,
125
- total,
126
- items: sorted.map((m) => ({
173
+ // rows are the model table, sorted by tokens, calls or cost, with each
174
+ // model's share of the total.
175
+ function rows(summary, sort, lang) {
176
+ const s = summary || {};
177
+ const models = Array.isArray(s.models) ? s.models : [];
178
+ const key = sort === "calls" ? "calls" : sort === "cost" ? "cost" : "tokens";
179
+ const value = (m) => (key === "tokens" ? (m.input || 0) + (m.output || 0) : m[key] || 0);
180
+ const sorted = models.slice().sort((a, b) => value(b) - value(a));
181
+ const total = sorted.reduce((a, m) => a + value(m), 0);
182
+ return sorted.map((m) => ({
127
183
  model: m.model || "?",
128
184
  calls: count(m.calls),
129
185
  tokens: count((m.input || 0) + (m.output || 0)),
130
186
  cost: money(m.cost),
131
187
  share: pct(value(m), total),
132
188
  shareValue: total > 0 ? value(m) / total : 0,
133
- })),
134
- };
135
- }
189
+ }));
190
+ }
136
191
 
137
- /** period labels, in the order magpie offers them. */
138
- export function periods(l = "en") {
139
- const t = TEXT[lang(l)];
140
- return ["today", "7d", "30d"].map((k) => ({ key: k, label: t.period[k] }));
141
- }
192
+ // since is when the period started.
193
+ function since(summary, lang) {
194
+ const t = text(lang);
195
+ const s = summary || {};
196
+ return s.since ? t.since + " " + s.since : "";
197
+ }
142
198
 
143
- /** sorts, in the order the page offers them. */
144
- export function sorts(l = "en") {
145
- const t = TEXT[lang(l)];
146
- return ["tokens", "calls", "cost"].map((k) => ({ key: k, label: t.sort[k] }));
147
- }
199
+ // periodLabel is the name of a period.
200
+ function periodLabel(period, lang) {
201
+ return text(lang)[period] || period;
202
+ }
148
203
 
149
- /** header is what the page says above the numbers. */
150
- export function header(summary, l = "en") {
151
- const t = TEXT[lang(l)];
152
- const s = summary || {};
153
- return {
154
- title: t.title,
155
- subtitle: t.subtitle,
156
- since: s.since ? t.since + " " + s.since : "",
157
- empty: !s.calls,
158
- emptyText: t.empty,
159
- error: t.error,
160
- retry: t.retry,
161
- disclaimer: t.disclaimer,
162
- status: t.status,
163
- statusWhere: t.statusWhere,
164
- restart: t.restart,
165
- };
166
- }
204
+ // sortLabel is the name of a sort.
205
+ function sortLabel(sort, lang) {
206
+ const t = text(lang);
207
+ return t["sort" + sort.charAt(0).toUpperCase() + sort.slice(1)] || sort;
208
+ }
167
209
 
168
- globalThis.MbridgeView = {
169
- TEXT,
170
- count,
171
- money,
172
- pct,
173
- tiles,
174
- rows,
175
- periods,
176
- sorts,
177
- header,
178
- };
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
+
277
+ globalThis.MbridgeView = {
278
+ TEXT,
279
+ PERIODS,
280
+ SORTS,
281
+ STATUS_URL,
282
+ text,
283
+ count,
284
+ money,
285
+ pct,
286
+ tiles,
287
+ rows,
288
+ since,
289
+ periodLabel,
290
+ sortLabel,
291
+ mbridgeView,
292
+ };
293
+ })();