@krtclcdy/tersio 2.18.0 → 2.19.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/README.md +2 -2
- package/cli/common.js +8 -2
- package/cli/currency.js +102 -0
- package/cli/dashboard.js +54 -2
- package/cli/doctor.js +2 -0
- package/cli/install.js +7 -73
- package/cli/profile.js +92 -0
- package/cli/reset.js +4 -2
- package/cli/settings.js +122 -0
- package/cli/usage.js +38 -24
- package/dashboard/app.js +297 -70
- package/dashboard/styles.css +58 -1
- package/dashboard/template.html +75 -3
- package/extensions/shared/rtk-gain.js +3 -6
- package/extensions/shared/usage-ledger.js +124 -103
- package/extensions/shared/usage-store.js +294 -0
- package/package.json +18 -1
- package/tersio.js +8 -1
package/cli/usage.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
|
|
1
|
+
import { co2GramsFor, displayModelId, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
|
|
4
2
|
import { readRtkGain } from '../extensions/shared/rtk-gain.js';
|
|
3
|
+
import { readUsageDb, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
|
|
5
4
|
import { withInteractiveSpinner } from './interactive.js';
|
|
6
|
-
import { PACKAGE_VERSION } from './common.js';
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// to nothing; 2000 groups covers a long history (~1.7k today) while still
|
|
11
|
-
// bounding /data.json.
|
|
5
|
+
import { PACKAGE_VERSION, currency } from './common.js';
|
|
6
|
+
import { formatCurrency } from './currency.js';
|
|
7
|
+
// RTK's history is machine-wide; rows group by command alone. 2000 groups
|
|
8
|
+
// covers a long history while still bounding /data.json.
|
|
12
9
|
const RTK_COMMAND_ROWS = 2000;
|
|
13
10
|
export function summarizeUsage(rows) {
|
|
14
11
|
refreshPricesIfStale();
|
|
@@ -19,7 +16,24 @@ export function summarizeUsage(rows) {
|
|
|
19
16
|
detailCounts[r.detail] = (detailCounts[r.detail] ?? 0) + 1;
|
|
20
17
|
}
|
|
21
18
|
const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
22
|
-
|
|
19
|
+
// usage.db is a cache of the same per-message rows: sync best-effort, read
|
|
20
|
+
// stored, fall back to the live parse when the store is missing or stale.
|
|
21
|
+
let synced = false;
|
|
22
|
+
try {
|
|
23
|
+
synced = syncUsageDb();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
synced = false;
|
|
27
|
+
}
|
|
28
|
+
let stored = null;
|
|
29
|
+
try {
|
|
30
|
+
stored = readUsageDb()?.tokens ?? null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
stored = null;
|
|
34
|
+
}
|
|
35
|
+
const session = stored ?? importSessionTokens();
|
|
36
|
+
const source = stored ? (synced ? 'stored' : 'stored-stale') : 'live';
|
|
23
37
|
const watermark = readResetWatermark();
|
|
24
38
|
let usd = 0;
|
|
25
39
|
let priced = true;
|
|
@@ -67,7 +81,9 @@ export function summarizeUsage(rows) {
|
|
|
67
81
|
co2g,
|
|
68
82
|
energyWh,
|
|
69
83
|
version: PACKAGE_VERSION,
|
|
70
|
-
|
|
84
|
+
currency,
|
|
85
|
+
source,
|
|
86
|
+
paths: { ledger: ledgerPath(), sessions: sessionsDir(), usageDb: usageDbPath() },
|
|
71
87
|
};
|
|
72
88
|
}
|
|
73
89
|
function fmt(n) {
|
|
@@ -97,8 +113,8 @@ function pad(s, n) {
|
|
|
97
113
|
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
|
98
114
|
}
|
|
99
115
|
// Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns
|
|
100
|
-
// right-align; long cells truncate with an ellipsis.
|
|
101
|
-
function
|
|
116
|
+
// right-align; long cells truncate with an ellipsis. Shared with cli/settings.ts.
|
|
117
|
+
function textTable(headers, rows, right = [], maxW = 32) {
|
|
102
118
|
const cells = [headers, ...rows].map((r) => r.map((c) => (c.length > maxW ? c.slice(0, maxW - 1) + '…' : c)));
|
|
103
119
|
const widths = headers.map((_, i) => Math.max(...cells.map((r) => r[i].length)));
|
|
104
120
|
const line = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
|
|
@@ -110,14 +126,14 @@ function table(headers, rows, right = [], maxW = 32) {
|
|
|
110
126
|
return out.map((l) => ' ' + l);
|
|
111
127
|
}
|
|
112
128
|
function printReport(report) {
|
|
113
|
-
console.log(`\n=== Tersio Usage v${report.version} · ${fmt(report.messages)} msgs ===`);
|
|
129
|
+
console.log(`\n=== Tersio Usage v${report.version} · ${fmt(report.messages)} msgs · ${report.source} ===`);
|
|
114
130
|
if (report.empty) {
|
|
115
131
|
console.log(' No ledger rows or session tokens yet — run session commands first.');
|
|
116
132
|
return;
|
|
117
133
|
}
|
|
118
134
|
const t = report.tokens;
|
|
119
135
|
console.log(` TOKENS ${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read${t.cacheWrite > 0 ? ` · ${fmt(t.cacheWrite)} written` : ''}`);
|
|
120
|
-
console.log(` COST
|
|
136
|
+
console.log(` COST ${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`);
|
|
121
137
|
const models = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 8);
|
|
122
138
|
if (models.length) {
|
|
123
139
|
console.log(' BY MODEL');
|
|
@@ -126,36 +142,34 @@ function printReport(report) {
|
|
|
126
142
|
const mt = b.input + b.output + b.cacheRead + b.cacheWrite;
|
|
127
143
|
const hit = b.input + b.cacheRead ? (b.cacheRead / (b.input + b.cacheRead)) * 100 : 0;
|
|
128
144
|
const usd = report.byModelUsd[model] ?? 0;
|
|
129
|
-
return [model,
|
|
145
|
+
return [displayModelId(model), fmt(b.input), fmt(b.output), `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, formatCurrency(usd, report.currency), `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
|
|
130
146
|
});
|
|
131
|
-
for (const l of
|
|
147
|
+
for (const l of textTable(['Model', 'Input', 'Output', 'Cache r/w', report.currency, 'Hit', 'Share'], mrows, [false, true, true, true, true, true, false])) {
|
|
132
148
|
console.log(l);
|
|
133
149
|
}
|
|
134
150
|
}
|
|
135
|
-
const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool,
|
|
151
|
+
const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
|
|
136
152
|
for (const r of report.rtkGain.byCommand) {
|
|
137
|
-
cmdRows.push({ name: r.command,
|
|
153
|
+
cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
|
|
138
154
|
}
|
|
139
155
|
cmdRows.sort((a, b) => b.count - a.count);
|
|
140
156
|
if (cmdRows.length) {
|
|
141
157
|
const g = report.rtkGain;
|
|
142
|
-
const projects = new Set(g.byCommand.map((r) => r.project).filter(Boolean));
|
|
143
158
|
const scope = g.commands
|
|
144
|
-
? ` COMMAND TOOLS ${fmt(
|
|
159
|
+
? ` COMMAND TOOLS ${fmt(cmdRows.length)} commands · ${fmt(g.commands)} runs · ${fmtShort(g.saved)} saved`
|
|
145
160
|
: ' COMMAND TOOLS';
|
|
146
161
|
console.log(scope);
|
|
147
162
|
const top = cmdRows.reduce((m, r) => Math.max(m, r.count), 1);
|
|
148
163
|
const crows = cmdRows.slice(0, 15).map((r, idx) => [
|
|
149
164
|
String(idx + 1),
|
|
150
165
|
r.name,
|
|
151
|
-
r.project ? path.basename(r.project) : '–',
|
|
152
166
|
fmt(r.count),
|
|
153
167
|
r.saved === null ? '–' : fmtShort(r.saved),
|
|
154
168
|
r.avgPct === null ? '–' : `${r.avgPct.toFixed(1)}%`,
|
|
155
169
|
r.avgMs === null ? '–' : fmtMs(r.avgMs),
|
|
156
170
|
bar(r.count / top, 8),
|
|
157
171
|
]);
|
|
158
|
-
for (const l of
|
|
172
|
+
for (const l of textTable(['#', 'Tool/Command', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, true, true, true, true, false], 30)) {
|
|
159
173
|
console.log(l);
|
|
160
174
|
}
|
|
161
175
|
}
|
|
@@ -164,4 +178,4 @@ async function runUsage() {
|
|
|
164
178
|
const report = await withInteractiveSpinner('Reading usage ledger', async () => summarizeUsage(readUsage()));
|
|
165
179
|
printReport(report);
|
|
166
180
|
}
|
|
167
|
-
export { runUsage };
|
|
181
|
+
export { runUsage, textTable };
|
package/dashboard/app.js
CHANGED
|
@@ -116,6 +116,14 @@
|
|
|
116
116
|
function setCur(k) {
|
|
117
117
|
fx.cur = k; active = keys.indexOf(k);
|
|
118
118
|
try { localStorage.setItem('tersio-fx-cur', fx.cur); } catch (e) { }
|
|
119
|
+
// Served mode: also persist server-side so close → reopen keeps the
|
|
120
|
+
// choice. Every `tersio gain` run is a fresh ephemeral port (a new
|
|
121
|
+
// origin), so localStorage alone cannot survive a restart.
|
|
122
|
+
if (window.location.protocol !== 'file:' && typeof fetch === 'function') {
|
|
123
|
+
try {
|
|
124
|
+
fetch('currency', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: k }) }).catch(function() { });
|
|
125
|
+
} catch (e) { }
|
|
126
|
+
}
|
|
119
127
|
paint();
|
|
120
128
|
if (DATA) render(DATA);
|
|
121
129
|
}
|
|
@@ -250,13 +258,29 @@
|
|
|
250
258
|
var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
251
259
|
return b.input + b.output + b.cacheRead + b.cacheWrite;
|
|
252
260
|
}
|
|
253
|
-
|
|
254
|
-
var DATA = null, RANGE = 'daily', cardsShown = false;
|
|
255
|
-
|
|
261
|
+
var DATA = null, cardsShown = false;
|
|
256
262
|
function topModels(byModel, n) {
|
|
257
263
|
return Object.keys(byModel).sort(function(a, b) { return modelTotal(byModel, b) - modelTotal(byModel, a); }).slice(0, n);
|
|
258
264
|
}
|
|
259
|
-
|
|
265
|
+
// Display form for folded keys: namespace stays lowercase, model segments
|
|
266
|
+
// title-case with version dots kept (`deepseek-v4.1-flash` →
|
|
267
|
+
// `Deepseek-V4.1-Flash`). Keys arrive folded, so this prettifies only.
|
|
268
|
+
function displayModel(m) {
|
|
269
|
+
var bare = String(m).replace(/(?::free|-free)$/i, '');
|
|
270
|
+
function cap(s) {
|
|
271
|
+
var low = s.toLowerCase();
|
|
272
|
+
if (low === 'openai') return 'OpenAI';
|
|
273
|
+
if (low === 'ai') return 'AI';
|
|
274
|
+
if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
|
|
275
|
+
if (s.length <= 2) return s.toUpperCase();
|
|
276
|
+
return s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
277
|
+
}
|
|
278
|
+
function seg(s) { return s.split('.').map(cap).join('.'); }
|
|
279
|
+
function words(s) { return s.split(/[-_:]+/).filter(Boolean).map(seg).join('-'); }
|
|
280
|
+
var slash = bare.indexOf('/');
|
|
281
|
+
if (slash >= 0) return bare.slice(0, slash).toLowerCase() + '/' + words(bare.slice(slash + 1));
|
|
282
|
+
return words(bare);
|
|
283
|
+
}
|
|
260
284
|
function allDays() {
|
|
261
285
|
var set = {};
|
|
262
286
|
Object.keys(DATA.byDay || {}).forEach(function(d) { set[d] = 1; });
|
|
@@ -293,7 +317,7 @@
|
|
|
293
317
|
var dt = new Date(key + 'T12:00:00');
|
|
294
318
|
return isNaN(dt) ? key : dt.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase();
|
|
295
319
|
}
|
|
296
|
-
function shortName(m) { return
|
|
320
|
+
function shortName(m) { var d = displayModel(m); return d.length > 22 ? d.slice(0, 21) + '...' : d; }
|
|
297
321
|
function tipDayHTML(key, total, rows) {
|
|
298
322
|
var h = '<div class="tt">' + dateHead(key) + '</div><div class="tv">' + fmtShort(total) + ' total</div>';
|
|
299
323
|
rows.forEach(function(r) {
|
|
@@ -418,6 +442,222 @@
|
|
|
418
442
|
el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
|
|
419
443
|
el.addEventListener('mouseleave', hideTip);
|
|
420
444
|
}
|
|
445
|
+
// Model detail dialog: shadcn chart language over daily model volume.
|
|
446
|
+
// byDayModel is keyed by DAY (Record<day, Record<model, tokens>>); the old
|
|
447
|
+
// mdSeries read it keyed by model and rendered all-zero charts. Area chart
|
|
448
|
+
// with gradient + hover crosshair, downsampled rounded bars, token-mix
|
|
449
|
+
// donut, KPI strip, 30/90/ALL range tabs.
|
|
450
|
+
var mdModel = null, mdSpan = 'all', mdHover = { pts: [], days: [] };
|
|
451
|
+
function mdSeries(m) {
|
|
452
|
+
var days = allDays();
|
|
453
|
+
var per = DATA.byDayModel || {};
|
|
454
|
+
return days.map(function(d) { return { day: d, v: ((per[d] || {})[m] || 0) }; });
|
|
455
|
+
}
|
|
456
|
+
function mdSlice(series) {
|
|
457
|
+
if (mdSpan === 'all' || series.length <= mdSpan) return series;
|
|
458
|
+
return series.slice(series.length - mdSpan);
|
|
459
|
+
}
|
|
460
|
+
function mdMonthTicks(days) {
|
|
461
|
+
if (!days.length) return [];
|
|
462
|
+
var seen = {}, out = [];
|
|
463
|
+
days.forEach(function(d) {
|
|
464
|
+
var t = d.slice(0, 7);
|
|
465
|
+
if (!seen[t]) { seen[t] = 1; out.push({ day: d, label: new Date(d + 'T12:00:00').toLocaleString('en-US', { month: 'short' }) }); }
|
|
466
|
+
});
|
|
467
|
+
return out.slice(-6);
|
|
468
|
+
}
|
|
469
|
+
function mdShortDay(d) { return new Date(d + 'T12:00:00').toLocaleString('en-US', { month: 'short', day: 'numeric' }); }
|
|
470
|
+
function mdAreaChart(svg, pts, days) {
|
|
471
|
+
var W = 560, H = 190, max = 1, i, x, y;
|
|
472
|
+
pts.forEach(function(p) { max = Math.max(max, p); });
|
|
473
|
+
var step = pts.length > 1 ? (W - 16) / (pts.length - 1) : 0;
|
|
474
|
+
function X(i) { return 8 + i * step; }
|
|
475
|
+
function Y(p) { return 14 + (1 - p / max) * (H - 36); }
|
|
476
|
+
var grid = '';
|
|
477
|
+
for (i = 1; i <= 3; i++) { y = 14 + (H - 36) * i / 3; grid += '<line class="grid" x1="8" y1="' + y.toFixed(1) + '" x2="' + (W - 8) + '" y2="' + y.toFixed(1) + '"/>'; }
|
|
478
|
+
var d = pts.map(function(p, i) { return (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(p).toFixed(1); }).join(' ');
|
|
479
|
+
var last = pts.length ? { x: X(pts.length - 1), y: Y(pts[pts.length - 1]) } : { x: 8, y: H - 22 };
|
|
480
|
+
svg.innerHTML = '<defs><linearGradient id="mdGrad" x1="0" y1="0" x2="0" y2="1">' +
|
|
481
|
+
'<stop offset="0" style="stop-color:var(--accent)" stop-opacity="0.35"/>' +
|
|
482
|
+
'<stop offset="1" style="stop-color:var(--accent)" stop-opacity="0"/></linearGradient></defs>' +
|
|
483
|
+
grid +
|
|
484
|
+
(pts.length ? '<path class="area" d="' + d + ' L' + last.x.toFixed(1) + ' ' + (H - 22) + ' L8 ' + (H - 22) + ' Z"/>' : '') +
|
|
485
|
+
(pts.length ? '<path class="line" d="' + d + '"/>' : '') +
|
|
486
|
+
(pts.length ? '<circle class="dot" cx="' + last.x.toFixed(1) + '" cy="' + last.y.toFixed(1) + '" r="4"/>' : '') +
|
|
487
|
+
(pts.length ? '<circle class="hoverdot" id="mdHoverDot" cx="-10" cy="-10" r="4" style="display:none"/>' : '');
|
|
488
|
+
mdHover = { pts: pts, days: days };
|
|
489
|
+
svg.onmousemove = function(ev) {
|
|
490
|
+
var tip = document.getElementById('mdTip');
|
|
491
|
+
if (!tip || !mdHover.pts.length) return;
|
|
492
|
+
var r = svg.getBoundingClientRect();
|
|
493
|
+
var fx = (ev.clientX - r.left) / r.width * W;
|
|
494
|
+
var idx = Math.max(0, Math.min(mdHover.pts.length - 1, Math.round((fx - 8) / (step || 1))));
|
|
495
|
+
var dot = document.getElementById('mdHoverDot');
|
|
496
|
+
if (dot) { dot.setAttribute('cx', X(idx).toFixed(1)); dot.setAttribute('cy', Y(mdHover.pts[idx]).toFixed(1)); dot.style.display = ''; }
|
|
497
|
+
tip.textContent = mdShortDay(mdHover.days[idx]) + ' · ' + fmtShort(mdHover.pts[idx]);
|
|
498
|
+
tip.style.display = 'block';
|
|
499
|
+
// Raw % positioning overflows past the dialog edge on the first/last
|
|
500
|
+
// points (the dialog clips it). Measure and pin inside the card.
|
|
501
|
+
var chart = svg.closest ? svg.closest('.md-chart') : null;
|
|
502
|
+
var px = svg.offsetLeft + (X(idx) / W) * svg.clientWidth;
|
|
503
|
+
if (chart) {
|
|
504
|
+
var half = tip.offsetWidth / 2;
|
|
505
|
+
px = Math.max(half + 4, Math.min(chart.clientWidth - half - 4, px));
|
|
506
|
+
}
|
|
507
|
+
tip.style.left = px + 'px';
|
|
508
|
+
tip.style.top = '8px';
|
|
509
|
+
};
|
|
510
|
+
svg.onmouseleave = function() {
|
|
511
|
+
var tip = document.getElementById('mdTip');
|
|
512
|
+
if (tip) tip.style.display = 'none';
|
|
513
|
+
var dot = document.getElementById('mdHoverDot');
|
|
514
|
+
if (dot) dot.style.display = 'none';
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function mdBars(el, vals, days) {
|
|
518
|
+
el.innerHTML = '';
|
|
519
|
+
var max = 1, i, total = 0, active = 0, peak = 0, peakI = 0;
|
|
520
|
+
vals.forEach(function(v, k) { max = Math.max(max, v); total += v; if (v > 0) { active++; if (v > peak) { peak = v; peakI = k; } } });
|
|
521
|
+
var n = vals.length, bucket = 1;
|
|
522
|
+
if (n > 90) bucket = Math.ceil(n / 90);
|
|
523
|
+
for (i = 0; i < n; i += bucket) {
|
|
524
|
+
var sum = 0, c = 0;
|
|
525
|
+
for (var j = i; j < Math.min(n, i + bucket); j++) { sum += vals[j]; c++; }
|
|
526
|
+
var avg = sum / c;
|
|
527
|
+
var s = document.createElement('span');
|
|
528
|
+
if (!avg) s.className = 'zero';
|
|
529
|
+
else if (avg === max) s.className = 'top';
|
|
530
|
+
s.style.height = Math.max(avg ? 5 : 2, Math.round(avg / max * 100)) + '%';
|
|
531
|
+
s.title = mdShortDay(days[i]) + ' · ' + fmtShort(Math.round(sum));
|
|
532
|
+
el.appendChild(s);
|
|
533
|
+
}
|
|
534
|
+
// Sparse models leave the bars mostly flat: name the peak day, its share
|
|
535
|
+
// of the period, and the per-active-day average instead of empty space.
|
|
536
|
+
var note = document.getElementById('mdBarsNote');
|
|
537
|
+
if (note) {
|
|
538
|
+
if (!active) note.textContent = 'no activity in range';
|
|
539
|
+
else note.textContent = 'Peak ' + mdShortDay(days[peakI]) + ' · ' + fmtShort(peak) + ' (' + (total ? Math.round(peak / total * 100) : 0) + '% of period) · avg ' + fmtShort(Math.round(total / active)) + ' / active day';
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function mdDonut(svg, parts) {
|
|
543
|
+
var r = 54, C = 2 * Math.PI * r, off = 0;
|
|
544
|
+
var total = parts.reduce(function(a, p) { return a + p.v; }, 0) || 1;
|
|
545
|
+
var html = '<circle class="tk" cx="70" cy="70" r="' + r + '"/>';
|
|
546
|
+
parts.forEach(function(p) {
|
|
547
|
+
var len = p.v / total * C;
|
|
548
|
+
if (len <= 0) return;
|
|
549
|
+
html += '<circle cx="70" cy="70" r="' + r + '" style="stroke:' + p.color + '" stroke-dasharray="' + len.toFixed(1) + ' ' + (C - len).toFixed(1) + '" stroke-dashoffset="' + (-off).toFixed(1) + '" stroke-linecap="butt"/>';
|
|
550
|
+
off += len;
|
|
551
|
+
});
|
|
552
|
+
svg.innerHTML = html;
|
|
553
|
+
document.getElementById('mdMixTotal').textContent = fmtShort(total);
|
|
554
|
+
document.getElementById('mdMixLegend').innerHTML = parts.filter(function(p) { return p.v > 0; }).map(function(p) {
|
|
555
|
+
return '<div class="row"><span class="dot" style="background:' + p.color + '"></span><span>' + p.label + '</span><span class="pct">' + (p.v / total * 100).toFixed(1) + '% · ' + fmtShort(p.v) + '</span></div>';
|
|
556
|
+
}).join('');
|
|
557
|
+
}
|
|
558
|
+
function mdMonths(el, days) {
|
|
559
|
+
el.innerHTML = '';
|
|
560
|
+
mdMonthTicks(days).forEach(function(t) {
|
|
561
|
+
var s = document.createElement('span');
|
|
562
|
+
s.textContent = t.label;
|
|
563
|
+
el.appendChild(s);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function mdKpi(k, v, d, up) {
|
|
567
|
+
return '<div class="md-kpi"><p class="k mono">' + k + '</p><p class="v">' + v + '</p><p class="d mono ' + (up ? 'up' : 'flat') + '">' + d + '</p></div>';
|
|
568
|
+
}
|
|
569
|
+
function mdStat(k, v, s) {
|
|
570
|
+
return '<div class="md-stat"><p class="k mono">' + k + '</p><p class="v mono">' + v + '</p>' + (s ? '<p class="s mono">' + s + '</p>' : '') + '</div>';
|
|
571
|
+
}
|
|
572
|
+
function mdRange(days) {
|
|
573
|
+
if (!days.length) return '–';
|
|
574
|
+
return mdShortDay(days[0]).toUpperCase() + ' → ' + mdShortDay(days[days.length - 1]).toUpperCase();
|
|
575
|
+
}
|
|
576
|
+
function mdTrendText(vals) {
|
|
577
|
+
var max = 0, i;
|
|
578
|
+
vals.forEach(function(x) { max = Math.max(max, x); });
|
|
579
|
+
if (vals.length < 14) return max ? { t: 'peak ' + fmtShort(max) + ' in a day', up: true } : { t: 'no activity yet', up: false };
|
|
580
|
+
var a = 0, b = 0;
|
|
581
|
+
for (i = vals.length - 14; i < vals.length; i++) a += vals[i];
|
|
582
|
+
for (i = Math.max(0, vals.length - 28); i < vals.length - 14; i++) b += vals[i];
|
|
583
|
+
if (!b) return { t: fmtShort(a) + ' / 2 wks', up: a > 0 };
|
|
584
|
+
var pct = (a - b) / b * 100;
|
|
585
|
+
return { t: (pct >= 0 ? '+' : '') + pct.toFixed(0) + '% vs prior 2 wks', up: pct >= 0 };
|
|
586
|
+
}
|
|
587
|
+
function renderModelDialog(m) {
|
|
588
|
+
var byModel = DATA.byModel || {};
|
|
589
|
+
var tops = topModels(byModel, Object.keys(byModel).length);
|
|
590
|
+
var rank = tops.indexOf(m) + 1;
|
|
591
|
+
var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
592
|
+
var total = modelTotal(byModel, m);
|
|
593
|
+
var grand = 1;
|
|
594
|
+
tops.forEach(function(k) { grand += modelTotal(byModel, k); });
|
|
595
|
+
var v = vendorOf(m);
|
|
596
|
+
var req = ((DATA.byModelMessages || {})[m]) || 0;
|
|
597
|
+
var usd = ((DATA.byModelUsd || {})[m]) || 0;
|
|
598
|
+
var denom = b.input + b.cacheRead;
|
|
599
|
+
var hit = denom ? (b.cacheRead / denom * 100) : 0;
|
|
600
|
+
var series = mdSlice(mdSeries(m));
|
|
601
|
+
var days = series.map(function(p) { return p.day; });
|
|
602
|
+
var vals = series.map(function(p) { return p.v; });
|
|
603
|
+
var active = vals.filter(function(x) { return x > 0; }).length;
|
|
604
|
+
var tr = mdTrendText(mdSeries(m).map(function(p) { return p.v; }));
|
|
605
|
+
document.getElementById('mdBadge').innerHTML = brandHTML(v, false);
|
|
606
|
+
var title = document.getElementById('mdTitle');
|
|
607
|
+
title.textContent = displayModel(m);
|
|
608
|
+
title.title = m;
|
|
609
|
+
document.getElementById('mdSub').textContent = v.name + ' · ' + fmt(req) + ' requests · ' + (total / grand * 100).toFixed(1) + '% of volume';
|
|
610
|
+
document.getElementById('mdRank').textContent = rank ? '#' + String(rank).padStart(2, '0') : '#–';
|
|
611
|
+
var trend = document.getElementById('mdTrend');
|
|
612
|
+
trend.textContent = tr.t;
|
|
613
|
+
trend.className = 'md-trend mono' + (tr.up ? ' up' : '');
|
|
614
|
+
document.getElementById('mdKpis').innerHTML =
|
|
615
|
+
mdKpi('Tokens', fmtShort(total), mdRange(days), true) +
|
|
616
|
+
mdKpi('Spend', fxMoney(usd), req ? fxMoney(usd / req) + ' / req' : 'no requests', usd > 0) +
|
|
617
|
+
mdKpi('Cache hit', hit.toFixed(0) + '%', fmtShort(b.cacheRead) + ' cached', hit >= 50) +
|
|
618
|
+
mdKpi('Requests', fmt(req), active + ' active days', active > 0);
|
|
619
|
+
document.getElementById('mdMomRange').textContent = mdRange(days);
|
|
620
|
+
mdAreaChart(document.getElementById('mdSpark'), vals, days);
|
|
621
|
+
mdMonths(document.getElementById('mdMonths'), days);
|
|
622
|
+
var act = document.getElementById('mdActive');
|
|
623
|
+
act.textContent = active + ' / ' + days.length + ' days';
|
|
624
|
+
act.className = 'md-trend mono' + (active ? ' up' : '');
|
|
625
|
+
mdBars(document.getElementById('mdBars'), vals, days);
|
|
626
|
+
mdMonths(document.getElementById('mdBarsMonths'), days);
|
|
627
|
+
mdDonut(document.getElementById('mdMix'), [
|
|
628
|
+
{ label: 'Input', v: b.input, color: 'var(--accent)' },
|
|
629
|
+
{ label: 'Output', v: b.output, color: '#818cf8' },
|
|
630
|
+
{ label: 'Cache read', v: b.cacheRead, color: '#22d3ee' },
|
|
631
|
+
{ label: 'Cache write', v: b.cacheWrite, color: 'var(--dim)' },
|
|
632
|
+
]);
|
|
633
|
+
document.getElementById('mdStats').innerHTML =
|
|
634
|
+
mdStat('Input', fmtShort(b.input), null) +
|
|
635
|
+
mdStat('Output', fmtShort(b.output), null) +
|
|
636
|
+
mdStat('Cache read', fmtShort(b.cacheRead), hit.toFixed(0) + '% hit') +
|
|
637
|
+
mdStat('Cache write', fmtShort(b.cacheWrite), null);
|
|
638
|
+
if (window.lucide) lucide.createIcons();
|
|
639
|
+
if (hasGsap && !reduce) {
|
|
640
|
+
gsap.fromTo('#modelDialog .md-kpi', { y: 12, opacity: 0.2 }, { y: 0, opacity: 1, duration: 0.4, ease: 'power2.out', stagger: 0.05, overwrite: true });
|
|
641
|
+
gsap.fromTo('#modelDialog .md-chart', { y: 14, opacity: 0.2 }, { y: 0, opacity: 1, duration: 0.45, ease: 'power2.out', stagger: 0.07, overwrite: true });
|
|
642
|
+
gsap.fromTo('#modelDialog .md-stat', { scale: 0.94, opacity: 0.2 }, { scale: 1, opacity: 1, duration: 0.4, ease: 'power2.out', stagger: 0.04, overwrite: true });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function openModelDialog(m) {
|
|
646
|
+
var dlg = document.getElementById('modelDialog');
|
|
647
|
+
if (!dlg || typeof dlg.showModal !== 'function') return;
|
|
648
|
+
mdModel = m;
|
|
649
|
+
Array.prototype.forEach.call(document.querySelectorAll('#modelDialog [data-mdspan]'), function(btn) {
|
|
650
|
+
var on = btn.getAttribute('data-mdspan') === String(mdSpan);
|
|
651
|
+
btn.classList.toggle('on', on);
|
|
652
|
+
btn.onclick = function() {
|
|
653
|
+
mdSpan = btn.getAttribute('data-mdspan') === 'all' ? 'all' : Number(btn.getAttribute('data-mdspan'));
|
|
654
|
+
Array.prototype.forEach.call(document.querySelectorAll('#modelDialog [data-mdspan]'), function(o) { o.classList.toggle('on', o === btn); });
|
|
655
|
+
renderModelDialog(mdModel);
|
|
656
|
+
};
|
|
657
|
+
});
|
|
658
|
+
renderModelDialog(m);
|
|
659
|
+
if (!dlg.open) dlg.showModal();
|
|
660
|
+
}
|
|
421
661
|
function dayModelRows(key) {
|
|
422
662
|
var per = (DATA.byDayModel || {})[key] || {};
|
|
423
663
|
var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
|
|
@@ -634,7 +874,8 @@
|
|
|
634
874
|
ghostSparkle.setAttribute('aria-hidden', 'true');
|
|
635
875
|
card.appendChild(ghostSparkle);
|
|
636
876
|
}
|
|
637
|
-
card.style.cssText = 'border: 1px solid var(--line); background: var(--panel)';
|
|
877
|
+
card.style.cssText = 'border: 1px solid var(--line); background: var(--panel); cursor: pointer';
|
|
878
|
+
card.setAttribute('role', 'button');
|
|
638
879
|
var head = document.createElement('p');
|
|
639
880
|
head.className = 'mono text-xs mb-3'; head.style.color = 'var(--dim)';
|
|
640
881
|
head.textContent = '0' + (i + 1);
|
|
@@ -644,13 +885,14 @@
|
|
|
644
885
|
badgeWrap.innerHTML = brandHTML(v, false);
|
|
645
886
|
var badge = badgeWrap.firstChild;
|
|
646
887
|
var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
|
|
647
|
-
var nm = document.createElement('p'); nm.className = 'mono text-sm font-bold truncate'; nm.textContent = m;
|
|
888
|
+
var nm = document.createElement('p'); nm.className = 'mono text-sm font-bold truncate'; nm.textContent = displayModel(m); nm.title = m;
|
|
648
889
|
var vn = document.createElement('p'); vn.className = 'mono text-xs truncate'; vn.style.color = 'var(--dim)'; vn.textContent = v.name;
|
|
649
890
|
mid.appendChild(nm); mid.appendChild(vn);
|
|
650
891
|
var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
|
|
651
892
|
row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
|
|
652
893
|
card.appendChild(head); card.appendChild(row);
|
|
653
894
|
hoverModel(card, m);
|
|
895
|
+
(function(model) { card.addEventListener('click', function() { openModelDialog(model); }); })(m);
|
|
654
896
|
cards.appendChild(card);
|
|
655
897
|
});
|
|
656
898
|
var ol = document.getElementById('models');
|
|
@@ -668,7 +910,7 @@
|
|
|
668
910
|
badgeWrap.innerHTML = brandHTML(v, true);
|
|
669
911
|
var badge = badgeWrap.firstChild;
|
|
670
912
|
var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
|
|
671
|
-
var nm = document.createElement('p'); nm.className = 'mono text-sm truncate'; nm.textContent = m;
|
|
913
|
+
var nm = document.createElement('p'); nm.className = 'mono text-sm truncate'; nm.textContent = displayModel(m); nm.title = m;
|
|
672
914
|
var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
|
|
673
915
|
var fill = document.createElement('div'); fill.className = 'bar-fill h-full';
|
|
674
916
|
var mv = modelTotal(byModel, m);
|
|
@@ -682,6 +924,7 @@
|
|
|
682
924
|
right.appendChild(n); right.appendChild(cost);
|
|
683
925
|
li.appendChild(badge); li.appendChild(mid); li.appendChild(right);
|
|
684
926
|
hoverModel(li, m);
|
|
927
|
+
(function(model) { li.addEventListener('click', function() { openModelDialog(model); }); })(m);
|
|
685
928
|
ol.appendChild(li);
|
|
686
929
|
});
|
|
687
930
|
document.getElementById('modelsCount').textContent = tops.length ? tops.length + ' models' : '';
|
|
@@ -712,43 +955,12 @@
|
|
|
712
955
|
|
|
713
956
|
// Union of session tool calls + RTK-metered commands, sortable + paged.
|
|
714
957
|
// Session tools were never metered per command: saved / avg / time show – and sort last.
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [], cmdProject = '';
|
|
958
|
+
// Rows group by command alone, machine-wide: same command in any repo is one row.
|
|
959
|
+
var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [];
|
|
718
960
|
function cmdVal(r, key) {
|
|
719
961
|
if (key === 'name') return r.name.toLowerCase();
|
|
720
|
-
if (key === 'project') return (r.project || '').toLowerCase();
|
|
721
962
|
return r[key];
|
|
722
963
|
}
|
|
723
|
-
function baseName(p) {
|
|
724
|
-
if (!p) return '–';
|
|
725
|
-
var parts = String(p).replace(/\/+$/, '').split('/');
|
|
726
|
-
return parts[parts.length - 1] || p;
|
|
727
|
-
}
|
|
728
|
-
// Options come from the rows themselves so the list follows the machine.
|
|
729
|
-
// Repaint is skipped while the option set is unchanged: render runs every
|
|
730
|
-
// 5s and a rebuild would close an open dropdown mid-selection.
|
|
731
|
-
function paintProjectFilter(all) {
|
|
732
|
-
var sel = document.getElementById('cmdProject');
|
|
733
|
-
if (!sel) return;
|
|
734
|
-
var seen = {};
|
|
735
|
-
all.forEach(function(r) { if (r.project) seen[r.project] = 1; });
|
|
736
|
-
var keys = Object.keys(seen).sort();
|
|
737
|
-
if (cmdProject && keys.indexOf(cmdProject) < 0) cmdProject = '';
|
|
738
|
-
var sig = keys.join('\n') + '\u0000' + cmdProject;
|
|
739
|
-
if (sel.dataset.sig === sig) return;
|
|
740
|
-
sel.dataset.sig = sig;
|
|
741
|
-
sel.innerHTML = '';
|
|
742
|
-
var any = document.createElement('option');
|
|
743
|
-
any.value = ''; any.textContent = 'All projects (' + keys.length + ')';
|
|
744
|
-
sel.appendChild(any);
|
|
745
|
-
keys.forEach(function(k) {
|
|
746
|
-
var o = document.createElement('option');
|
|
747
|
-
o.value = k; o.textContent = baseName(k); o.title = k;
|
|
748
|
-
sel.appendChild(o);
|
|
749
|
-
});
|
|
750
|
-
sel.value = cmdProject;
|
|
751
|
-
}
|
|
752
964
|
function sortCmd() {
|
|
753
965
|
var k = cmdSort.key, d = cmdSort.dir;
|
|
754
966
|
cmdRows.sort(function(a, b) {
|
|
@@ -767,14 +979,16 @@
|
|
|
767
979
|
}
|
|
768
980
|
function renderCmd() {
|
|
769
981
|
var g = (DATA.rtkGain || {});
|
|
770
|
-
var
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
982
|
+
var byName = {};
|
|
983
|
+
function add(name, count, saved, avgPct, avgMs) {
|
|
984
|
+
var r = byName[name];
|
|
985
|
+
if (!r) { byName[name] = { name: name, count: count, saved: saved, avgPct: avgPct, avgMs: avgMs }; return; }
|
|
986
|
+
r.count += count;
|
|
987
|
+
if (saved !== null && saved !== undefined) r.saved = (r.saved || 0) + saved;
|
|
988
|
+
}
|
|
989
|
+
(DATA.byTool || []).forEach(function(r) { add(r[0], r[1], null, null, null); });
|
|
990
|
+
(g.byCommand || []).forEach(function(r) { add(r.command, r.count, r.saved, r.avgPct, r.avgMs); });
|
|
991
|
+
cmdRows = Object.keys(byName).map(function(k) { return byName[k]; });
|
|
778
992
|
sortCmd();
|
|
779
993
|
var pages = Math.max(1, Math.ceil(cmdRows.length / cmdPer));
|
|
780
994
|
if (cmdPage > pages) cmdPage = pages;
|
|
@@ -789,7 +1003,6 @@
|
|
|
789
1003
|
tr.className = 'mrow' + (i < Math.min(cmdRows.length - start, cmdPer) - 1 ? ' rowline' : '');
|
|
790
1004
|
tr.innerHTML = '<td class="text-right pr-3 py-2.5 mono text-xs w-10" style="color: var(--dim)"></td>' +
|
|
791
1005
|
'<td class="py-2.5 pr-3 truncate" style="max-width: 280px"></td>' +
|
|
792
|
-
'<td class="py-2.5 pr-3 truncate" style="max-width: 120px; color: var(--dim)"></td>' +
|
|
793
1006
|
'<td class="text-right py-2.5 pr-3 font-bold"></td>' +
|
|
794
1007
|
'<td class="text-right py-2.5 pr-3 font-bold"></td>' +
|
|
795
1008
|
'<td class="text-right py-2.5 pr-3" style="color: var(--accent)"></td>' +
|
|
@@ -799,20 +1012,17 @@
|
|
|
799
1012
|
tds[0].textContent = String(n + 1).padStart(2, '0');
|
|
800
1013
|
tds[1].textContent = r.name;
|
|
801
1014
|
tds[1].title = r.name;
|
|
802
|
-
tds[2].textContent =
|
|
803
|
-
tds[
|
|
804
|
-
tds[
|
|
805
|
-
tds[
|
|
806
|
-
tds[
|
|
807
|
-
tds[6].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
|
|
808
|
-
tds[7].firstChild.firstChild.style.width = r.count ? Math.round(r.count / max * 100) + '%' : '0';
|
|
1015
|
+
tds[2].textContent = fmt(r.count);
|
|
1016
|
+
tds[3].textContent = r.saved === null ? '–' : fmtShort(r.saved);
|
|
1017
|
+
tds[4].textContent = r.avgPct === null ? '–' : r.avgPct.toFixed(1) + '%';
|
|
1018
|
+
tds[5].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
|
|
1019
|
+
tds[6].firstChild.firstChild.style.width = r.count ? Math.round(r.count / max * 100) + '%' : '0';
|
|
809
1020
|
body.appendChild(tr);
|
|
810
1021
|
});
|
|
811
1022
|
document.getElementById('cmdTable').style.display = cmdRows.length ? '' : 'none';
|
|
812
1023
|
document.getElementById('emptyCmd').classList.toggle('hidden', cmdRows.length > 0);
|
|
813
|
-
var tools = (DATA.byTool || []).length;
|
|
814
1024
|
document.getElementById('toolsScope').textContent =
|
|
815
|
-
g.commands ? fmt(
|
|
1025
|
+
g.commands ? fmt(cmdRows.length) + ' commands · ' + fmt(g.commands) + ' runs · ' + fmtShort(g.saved) + ' saved' : 'tool calls in sessions';
|
|
816
1026
|
paintCmdPager(pages);
|
|
817
1027
|
}
|
|
818
1028
|
// Shared page-number painter: prev/next plus windowed numbers with
|
|
@@ -882,8 +1092,6 @@
|
|
|
882
1092
|
});
|
|
883
1093
|
});
|
|
884
1094
|
bindPerPage('#cmdPager', function() { return cmdPer; }, function(n) { cmdPer = n; cmdPage = 1; }, renderCmd);
|
|
885
|
-
var pf = document.getElementById('cmdProject');
|
|
886
|
-
if (pf) pf.addEventListener('change', function() { cmdProject = pf.value; cmdPage = 1; renderCmd(); });
|
|
887
1095
|
bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
|
|
888
1096
|
bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
|
|
889
1097
|
}
|
|
@@ -992,6 +1200,22 @@
|
|
|
992
1200
|
|
|
993
1201
|
function render(d) {
|
|
994
1202
|
DATA = d;
|
|
1203
|
+
// Server-provided default currency (tersio gain --currency); a saved
|
|
1204
|
+
// picker choice in localStorage always wins.
|
|
1205
|
+
if (!render.fxInit) {
|
|
1206
|
+
render.fxInit = true;
|
|
1207
|
+
var saved = null;
|
|
1208
|
+
try { saved = localStorage.getItem('tersio-fx-cur'); } catch (e) { }
|
|
1209
|
+
if (!saved && d.currency && CURS[d.currency]) {
|
|
1210
|
+
fx.cur = d.currency;
|
|
1211
|
+
var curEl = document.getElementById('fxCur');
|
|
1212
|
+
if (curEl) curEl.textContent = FLAGS[fx.cur] + ' ' + fx.cur;
|
|
1213
|
+
var panel = document.getElementById('fxPanel');
|
|
1214
|
+
if (panel) Array.prototype.forEach.call(panel.children, function(o) {
|
|
1215
|
+
o.setAttribute('aria-selected', o.dataset.cur === fx.cur ? 'true' : 'false');
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
995
1219
|
var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
996
1220
|
var total = t.input + t.output + t.cacheRead + t.cacheWrite;
|
|
997
1221
|
var totalEl = document.getElementById('total');
|
|
@@ -1008,7 +1232,7 @@
|
|
|
1008
1232
|
renderSpark(byDay, d.usd || 0, d.savedUsd || 0, total);
|
|
1009
1233
|
document.getElementById('verChip').textContent = 'tersio v' + (d.version || '?');
|
|
1010
1234
|
var dp = d.paths || {};
|
|
1011
|
-
[['pathLedger', dp.ledger], ['pathSessions', dp.sessions]].forEach(function(pair) {
|
|
1235
|
+
[['pathLedger', dp.ledger], ['pathSessions', dp.sessions], ['pathUsageDb', dp.usageDb]].forEach(function(pair) {
|
|
1012
1236
|
var pel = document.getElementById(pair[0]);
|
|
1013
1237
|
if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
|
|
1014
1238
|
});
|
|
@@ -1145,7 +1369,7 @@
|
|
|
1145
1369
|
var tds = tr.children;
|
|
1146
1370
|
var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
|
|
1147
1371
|
tds[0].innerHTML = dot + '<span></span>';
|
|
1148
|
-
tds[0].querySelector('span:last-child').textContent = r.m;
|
|
1372
|
+
tds[0].querySelector('span:last-child').textContent = displayModel(r.m);
|
|
1149
1373
|
tds[1].textContent = fmt(r.i);
|
|
1150
1374
|
tds[2].textContent = fmt(r.o);
|
|
1151
1375
|
tds[3].textContent = speedText(r);
|
|
@@ -1158,7 +1382,6 @@
|
|
|
1158
1382
|
: 'est. — modeled from tokens');
|
|
1159
1383
|
if (measured) tds[5].style.color = 'var(--ink)';
|
|
1160
1384
|
tds[6].textContent = whenStamp(r.t);
|
|
1161
|
-
tds[6].title = stampLocal(r.t);
|
|
1162
1385
|
hoverRecent(tr, r);
|
|
1163
1386
|
body.appendChild(tr);
|
|
1164
1387
|
});
|
|
@@ -1232,12 +1455,16 @@
|
|
|
1232
1455
|
document.getElementById('reload').addEventListener('click', load);
|
|
1233
1456
|
(function() {
|
|
1234
1457
|
var dlg = document.getElementById('settings');
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1458
|
+
if (dlg && typeof dlg.showModal === 'function') {
|
|
1459
|
+
document.getElementById('settingsBtn').addEventListener('click', function() { dlg.showModal(); });
|
|
1460
|
+
document.getElementById('settingsClose').addEventListener('click', function() { dlg.close(); });
|
|
1461
|
+
dlg.addEventListener('click', function(ev) { if (ev.target === dlg) dlg.close(); });
|
|
1462
|
+
}
|
|
1463
|
+
var md = document.getElementById('modelDialog');
|
|
1464
|
+
if (md && typeof md.showModal === 'function') {
|
|
1465
|
+
document.getElementById('mdClose').addEventListener('click', function() { md.close(); });
|
|
1466
|
+
md.addEventListener('click', function(ev) { if (ev.target === md) md.close(); });
|
|
1467
|
+
}
|
|
1241
1468
|
})();
|
|
1242
1469
|
(function() {
|
|
1243
1470
|
var resetBtn = document.getElementById('reset');
|