@weiping/mbridge-page 0.1.0 → 0.1.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 +8 -2
- package/page/app.js +138 -146
- package/page/index.html +28 -22
- package/page/style.css +18 -16
- package/page/view.js +155 -160
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weiping/mbridge-page",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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": [
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
"file": "page/index.html",
|
|
34
34
|
"icon": "page/icon.svg"
|
|
35
35
|
},
|
|
36
|
-
"permissions": [
|
|
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,153 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
const theme = await magpie.ready;
|
|
12
|
+
let lang = theme.lang;
|
|
13
|
+
|
|
14
|
+
// the period and the sort live in this plugin's own options, so the
|
|
15
|
+
// page opens where it was left
|
|
16
|
+
let opts = {};
|
|
17
|
+
try {
|
|
18
|
+
opts = (await magpie.options.get()) || {};
|
|
19
|
+
} catch {
|
|
20
|
+
// no options yet: the defaults in package.json stand
|
|
21
|
+
}
|
|
22
|
+
const state = {
|
|
23
|
+
period: V.PERIODS.includes(opts.period) ? opts.period : "7d",
|
|
24
|
+
sort: V.SORTS.includes(opts.sort) ? opts.sort : "tokens",
|
|
25
|
+
summary: null,
|
|
26
|
+
error: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function words() {
|
|
30
|
+
const t = V.text(lang);
|
|
31
|
+
document.querySelectorAll("[data-t]").forEach((e) => {
|
|
32
|
+
e.textContent = t[e.dataset.t];
|
|
33
|
+
});
|
|
34
|
+
$("status").textContent = t.status;
|
|
35
|
+
$("restart").textContent = t.restart;
|
|
36
|
+
$("cmd").textContent = "mbridge service restart";
|
|
26
37
|
}
|
|
27
|
-
}
|
|
28
|
-
|
|
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;
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
segment($("sorts"), sorts(state.lang), state.sort, (k) => {
|
|
42
|
-
state.sort = k;
|
|
43
|
-
save();
|
|
44
|
-
render();
|
|
45
|
-
});
|
|
39
|
+
function pick(group, attr, value) {
|
|
40
|
+
$(group).querySelectorAll("button").forEach((b) => {
|
|
41
|
+
b.setAttribute("aria-selected", String(b.dataset[attr] === value));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
function paint() {
|
|
46
|
+
pick("periods", "period", state.period);
|
|
47
|
+
pick("sorts", "sort", state.sort);
|
|
48
|
+
const s = state.summary;
|
|
49
|
+
if (!s) return;
|
|
50
|
+
$("since").textContent = V.since(s, lang);
|
|
51
|
+
|
|
52
|
+
$("tiles").replaceChildren(
|
|
53
|
+
...V.tiles(s, lang).map((tile) => {
|
|
54
|
+
const cell = document.createElement("div");
|
|
55
|
+
cell.className = "tile";
|
|
56
|
+
for (const [cls, value] of [["k", tile.label], ["v", tile.value], ["s", tile.sub]]) {
|
|
57
|
+
if (value === undefined) continue;
|
|
58
|
+
const e = document.createElement("div");
|
|
59
|
+
e.className = cls;
|
|
60
|
+
e.textContent = value;
|
|
61
|
+
cell.append(e);
|
|
62
|
+
}
|
|
63
|
+
return cell;
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const rows = V.rows(s, state.sort, lang);
|
|
68
|
+
$("rows").replaceChildren(
|
|
69
|
+
...rows.map((r) => {
|
|
70
|
+
const tr = document.createElement("tr");
|
|
71
|
+
const name = document.createElement("td");
|
|
72
|
+
name.className = "model";
|
|
73
|
+
name.textContent = r.model;
|
|
74
|
+
const bar = document.createElement("span");
|
|
75
|
+
bar.className = "bar";
|
|
76
|
+
const fill = document.createElement("i");
|
|
77
|
+
fill.style.width = Math.max(2, Math.round(r.shareValue * 100)) + "%";
|
|
78
|
+
bar.append(fill);
|
|
79
|
+
name.append(bar);
|
|
80
|
+
const calls = document.createElement("td");
|
|
81
|
+
calls.className = "num";
|
|
82
|
+
calls.textContent = r.calls;
|
|
83
|
+
const tokens = document.createElement("td");
|
|
84
|
+
tokens.className = "num";
|
|
85
|
+
tokens.textContent = r.tokens;
|
|
86
|
+
const cost = document.createElement("td");
|
|
87
|
+
cost.className = "num";
|
|
88
|
+
cost.textContent = r.cost;
|
|
89
|
+
tr.append(name, calls, tokens, cost);
|
|
90
|
+
return tr;
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
$("empty").hidden = rows.length > 0;
|
|
94
|
+
$("empty").textContent = V.text(lang).empty;
|
|
95
|
+
$("error").hidden = !state.error;
|
|
96
|
+
if (state.error) {
|
|
97
|
+
$("error").textContent = V.text(lang).failed + ": " + state.error + " · " + V.text(lang).retry;
|
|
65
98
|
}
|
|
66
|
-
box.appendChild(cell);
|
|
67
99
|
}
|
|
68
100
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
101
|
+
async function load() {
|
|
102
|
+
try {
|
|
103
|
+
state.summary = await magpie.read("usage:summary", { period: state.period });
|
|
104
|
+
state.error = null;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
state.error = (e && e.message) || String(e);
|
|
96
107
|
}
|
|
97
|
-
|
|
108
|
+
paint();
|
|
98
109
|
}
|
|
99
|
-
$("empty").textContent = r.items.length ? "" : t.emptyText;
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
// magpie takes one options write a second: a quick second click is
|
|
112
|
+
// saved with the next one
|
|
113
|
+
let saving = null;
|
|
114
|
+
function save() {
|
|
115
|
+
if (saving) return;
|
|
116
|
+
saving = setTimeout(async () => {
|
|
117
|
+
saving = null;
|
|
118
|
+
try {
|
|
119
|
+
opts = (await magpie.options.set({ ...opts, period: state.period, sort: state.sort })) || opts;
|
|
120
|
+
} catch {
|
|
121
|
+
// not remembering is not worth an error on the page
|
|
122
|
+
}
|
|
123
|
+
}, 1100);
|
|
124
|
+
}
|
|
109
125
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
$("periods").addEventListener("click", (e) => {
|
|
127
|
+
const b = e.target.closest("button[data-period]");
|
|
128
|
+
if (!b || b.dataset.period === state.period) return;
|
|
129
|
+
state.period = b.dataset.period;
|
|
130
|
+
save();
|
|
131
|
+
load();
|
|
132
|
+
});
|
|
116
133
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
state.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
render();
|
|
125
|
-
}
|
|
134
|
+
$("sorts").addEventListener("click", (e) => {
|
|
135
|
+
const b = e.target.closest("button[data-sort]");
|
|
136
|
+
if (!b || b.dataset.sort === state.sort) return;
|
|
137
|
+
state.sort = b.dataset.sort;
|
|
138
|
+
save();
|
|
139
|
+
paint();
|
|
140
|
+
});
|
|
126
141
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
142
|
+
// the period and sort buttons carry their own words
|
|
143
|
+
for (const b of $("periods").querySelectorAll("button[data-period]")) {
|
|
144
|
+
b.textContent = V.periodLabel(b.dataset.period, lang);
|
|
136
145
|
}
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
146
|
+
for (const b of $("sorts").querySelectorAll("button[data-sort]")) {
|
|
147
|
+
b.textContent = V.sortLabel(b.dataset.sort, lang);
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
-
|
|
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();
|
|
157
|
-
});
|
|
158
|
-
await restore();
|
|
149
|
+
$("addr").textContent = V.STATUS_URL;
|
|
150
|
+
words();
|
|
159
151
|
await load();
|
|
160
|
-
setInterval(load,
|
|
161
|
-
})();
|
|
152
|
+
setInterval(load, 60000);
|
|
153
|
+
})();
|
package/page/index.html
CHANGED
|
@@ -9,50 +9,56 @@
|
|
|
9
9
|
<script src="view.js"></script>
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
|
-
<main
|
|
12
|
+
<main class="root">
|
|
13
13
|
<header class="head">
|
|
14
|
-
<h1
|
|
15
|
-
<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
|
|
20
|
-
|
|
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
|
|
32
|
+
<section class="tiles" id="tiles"></section>
|
|
24
33
|
|
|
25
34
|
<section class="models">
|
|
26
|
-
<
|
|
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
|
|
34
|
-
<th class="num"
|
|
35
|
-
<th class="num"
|
|
36
|
-
<th class="num"
|
|
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
|
|
47
|
+
<p class="muted empty" id="empty" hidden></p>
|
|
42
48
|
</section>
|
|
43
49
|
|
|
44
50
|
<section class="notes">
|
|
45
|
-
<p
|
|
51
|
+
<p class="note" data-t="disclaimer"></p>
|
|
46
52
|
<p class="note">
|
|
47
|
-
<span id="status
|
|
48
|
-
<span
|
|
49
|
-
<span id="restart
|
|
50
|
-
<code class="cmd"
|
|
53
|
+
<span id="status"></span>
|
|
54
|
+
<span class="addr" id="addr" title="select and copy it"></span>
|
|
55
|
+
<span id="restart"></span>
|
|
56
|
+
<code class="cmd" id="cmd"></code>
|
|
51
57
|
</p>
|
|
52
58
|
</section>
|
|
53
59
|
|
|
54
|
-
<p
|
|
60
|
+
<p class="error" id="error" hidden></p>
|
|
55
61
|
</main>
|
|
56
62
|
<script src="app.js" defer></script>
|
|
57
63
|
</body>
|
|
58
|
-
</html>
|
|
64
|
+
</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
|
-
|
|
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
|
-
|
|
11
|
-
.subtitle { margin: 2px 0
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
|
28
|
-
.tile
|
|
29
|
-
.tile
|
|
30
|
-
.
|
|
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;
|
|
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,8 @@
|
|
|
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);
|
|
48
|
+
.error { color: var(--magpie-danger, #b42318); margin: 8px 0 0; }
|
package/page/view.js
CHANGED
|
@@ -1,178 +1,173 @@
|
|
|
1
|
-
// view.js is
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
},
|
|
37
|
+
zh: {
|
|
38
|
+
title: "mbridge",
|
|
39
|
+
subtitle: "magpie 记下的全部用量",
|
|
40
|
+
calls: "调用",
|
|
41
|
+
errors: "错误",
|
|
42
|
+
errorShare: "占调用",
|
|
43
|
+
tokens: "Token",
|
|
44
|
+
cost: "费用",
|
|
45
|
+
models: "按模型",
|
|
46
|
+
model: "模型",
|
|
47
|
+
today: "今天",
|
|
48
|
+
"7d": "7 天",
|
|
49
|
+
"30d": "30 天",
|
|
50
|
+
sortTokens: "Token",
|
|
51
|
+
sortCalls: "调用",
|
|
52
|
+
sortCost: "费用",
|
|
53
|
+
empty: "这个区间没有调用",
|
|
54
|
+
failed: "读不到 magpie 的用量",
|
|
55
|
+
retry: "一分钟后自动重试",
|
|
56
|
+
since: "起",
|
|
57
|
+
disclaimer:
|
|
58
|
+
"这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
|
|
59
|
+
status: "mbridge 自己的状态:",
|
|
60
|
+
restart: "重启用",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
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
|
-
}
|
|
64
|
+
const PERIODS = ["today", "7d", "30d"];
|
|
65
|
+
const SORTS = ["tokens", "calls", "cost"];
|
|
66
|
+
const STATUS_URL = "http://127.0.0.1:3426/status";
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
}
|
|
68
|
+
// text is the words for a language: zh for anything that starts with zh.
|
|
69
|
+
function text(lang) {
|
|
70
|
+
return String(lang || "").toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
|
|
71
|
+
}
|
|
77
72
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
73
|
+
// count is a number a person reads: 1,234 or 1.23M.
|
|
74
|
+
function count(n) {
|
|
75
|
+
n = Number(n) || 0;
|
|
76
|
+
const a = Math.abs(n);
|
|
77
|
+
if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
|
78
|
+
if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
79
|
+
if (a >= 1e3) return n.toLocaleString("en-US");
|
|
80
|
+
return String(n);
|
|
81
|
+
}
|
|
87
82
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
// money is a price, never NaN, and never "$0.00" for something real.
|
|
84
|
+
function money(n) {
|
|
85
|
+
n = Number(n);
|
|
86
|
+
if (!isFinite(n)) return "$0";
|
|
87
|
+
if (n === 0) return "$0";
|
|
88
|
+
if (n > 0 && n < 0.01) return "<$0.01";
|
|
89
|
+
if (n < 10) return "$" + n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
|
90
|
+
if (n < 1000) return "$" + n.toFixed(2);
|
|
91
|
+
return "$" + n.toFixed(0);
|
|
92
|
+
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
}
|
|
94
|
+
// share is a percentage, and never NaN.
|
|
95
|
+
function pct(part, whole) {
|
|
96
|
+
const p = Number(part) || 0;
|
|
97
|
+
const w = Number(whole) || 0;
|
|
98
|
+
if (w <= 0) return "0%";
|
|
99
|
+
const v = (100 * p) / w;
|
|
100
|
+
if (!isFinite(v)) return "0%";
|
|
101
|
+
return (Math.round(v * 10) / 10).toString() + "%";
|
|
102
|
+
}
|
|
108
103
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
104
|
+
// tiles are the four numbers at the top: calls, errors, tokens, cost.
|
|
105
|
+
function tiles(summary, lang) {
|
|
106
|
+
const t = text(lang);
|
|
107
|
+
const s = summary || {};
|
|
108
|
+
return [
|
|
109
|
+
{ key: "calls", label: t.calls, value: count(s.calls) },
|
|
110
|
+
{
|
|
111
|
+
key: "errors",
|
|
112
|
+
label: t.errors,
|
|
113
|
+
value: count(s.errors),
|
|
114
|
+
sub: t.errorShare + " " + pct(s.errors, s.calls),
|
|
115
|
+
},
|
|
116
|
+
{ key: "tokens", label: t.tokens, value: count((s.input || 0) + (s.output || 0)) },
|
|
117
|
+
{ key: "cost", label: t.cost, value: money(s.cost) },
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// rows are the model table, sorted by tokens, calls or cost, with each
|
|
122
|
+
// model's share of the total.
|
|
123
|
+
function rows(summary, sort, lang) {
|
|
124
|
+
const s = summary || {};
|
|
125
|
+
const models = Array.isArray(s.models) ? s.models : [];
|
|
126
|
+
const key = sort === "calls" ? "calls" : sort === "cost" ? "cost" : "tokens";
|
|
127
|
+
const value = (m) => (key === "tokens" ? (m.input || 0) + (m.output || 0) : m[key] || 0);
|
|
128
|
+
const sorted = models.slice().sort((a, b) => value(b) - value(a));
|
|
129
|
+
const total = sorted.reduce((a, m) => a + value(m), 0);
|
|
130
|
+
return sorted.map((m) => ({
|
|
127
131
|
model: m.model || "?",
|
|
128
132
|
calls: count(m.calls),
|
|
129
133
|
tokens: count((m.input || 0) + (m.output || 0)),
|
|
130
134
|
cost: money(m.cost),
|
|
131
135
|
share: pct(value(m), total),
|
|
132
136
|
shareValue: total > 0 ? value(m) / total : 0,
|
|
133
|
-
}))
|
|
134
|
-
}
|
|
135
|
-
}
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
136
139
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
// since is when the period started.
|
|
141
|
+
function since(summary, lang) {
|
|
142
|
+
const t = text(lang);
|
|
143
|
+
const s = summary || {};
|
|
144
|
+
return s.since ? t.since + " " + s.since : "";
|
|
145
|
+
}
|
|
142
146
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
147
|
+
// periodLabel is the name of a period.
|
|
148
|
+
function periodLabel(period, lang) {
|
|
149
|
+
return text(lang)[period] || period;
|
|
150
|
+
}
|
|
148
151
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
}
|
|
152
|
+
// sortLabel is the name of a sort.
|
|
153
|
+
function sortLabel(sort, lang) {
|
|
154
|
+
const t = text(lang);
|
|
155
|
+
return t["sort" + sort.charAt(0).toUpperCase() + sort.slice(1)] || sort;
|
|
156
|
+
}
|
|
167
157
|
|
|
168
|
-
globalThis.MbridgeView = {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
158
|
+
globalThis.MbridgeView = {
|
|
159
|
+
TEXT,
|
|
160
|
+
PERIODS,
|
|
161
|
+
SORTS,
|
|
162
|
+
STATUS_URL,
|
|
163
|
+
text,
|
|
164
|
+
count,
|
|
165
|
+
money,
|
|
166
|
+
pct,
|
|
167
|
+
tiles,
|
|
168
|
+
rows,
|
|
169
|
+
since,
|
|
170
|
+
periodLabel,
|
|
171
|
+
sortLabel,
|
|
172
|
+
};
|
|
173
|
+
})();
|