@ugurcandede/cc-cost 0.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/aggregate.js +107 -0
- package/dist/cli.js +351 -0
- package/dist/config.js +15 -0
- package/dist/dashboard.js +692 -0
- package/dist/format.js +33 -0
- package/dist/i18n.js +415 -0
- package/dist/paths.js +38 -0
- package/dist/pricing.js +136 -0
- package/dist/reports.js +410 -0
- package/dist/scan.js +178 -0
- package/dist/setup.js +226 -0
- package/dist/snapshot.js +97 -0
- package/dist/sync.js +17 -0
- package/dist/types.js +23 -0
- package/package.json +76 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { agentOf, contextTokens, groupBy, modelLabel } from "./aggregate.js";
|
|
3
|
+
import { LANGS } from "./i18n.js";
|
|
4
|
+
import { localClock } from "./scan.js";
|
|
5
|
+
import { T, UNKNOWN } from "./types.js";
|
|
6
|
+
// Chart colors by model, fixed so a model keeps its color whatever the filter.
|
|
7
|
+
// Append new models at the end; inserting shifts every color after it. Unlisted models get slot 8.
|
|
8
|
+
export const ORDER = ['claude-opus-5', 'claude-fable-5-1', 'claude-fable-5', 'claude-sonnet-5',
|
|
9
|
+
'claude-haiku-4-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-opus-5-fast'];
|
|
10
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
11
|
+
// JSON inside <script>: keep "</script>" in a project name from closing the tag
|
|
12
|
+
const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
13
|
+
const r4 = (n) => Math.round(n * 1e4) / 1e4;
|
|
14
|
+
// Header icons. GitHub mark from Octicons (MIT), npm mark from Simple Icons (CC0).
|
|
15
|
+
const ICON = {
|
|
16
|
+
logo: `<svg viewBox="0 0 120 120" aria-hidden="true"><rect width="120" height="120" rx="28" fill="#D97757"/><path fill="#fff" d="M40 24H80Q86 24 86 30V96L81.67 91L77.33 96L73 91L68.67 96L64.33 91L60 96L55.67 91L51.33 96L47 91L42.67 96L38.33 91L34 96V30Q34 24 40 24Z"/><rect x="42" y="62" width="8" height="18" rx="2" fill="#D97757"/><rect x="56" y="50" width="8" height="30" rx="2" fill="#D97757"/><rect x="70" y="36" width="8" height="44" rx="2" fill="#D97757"/></svg>`,
|
|
17
|
+
github: `<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>`,
|
|
18
|
+
npm: `<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"/></svg>`,
|
|
19
|
+
globe: `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9S9.5 5.7 12 3z"/></svg>`,
|
|
20
|
+
};
|
|
21
|
+
// Strings repeat across thousands of rows; store each once and refer to it by index.
|
|
22
|
+
function dictionary() {
|
|
23
|
+
const values = [], index = new Map();
|
|
24
|
+
return {
|
|
25
|
+
values,
|
|
26
|
+
id(v) {
|
|
27
|
+
let i = index.get(v);
|
|
28
|
+
if (i === undefined)
|
|
29
|
+
index.set(v, (i = values.push(v) - 1));
|
|
30
|
+
return i;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Row layout shared with the page script below (R.* indices)
|
|
35
|
+
function encode(rows, limits, timezone) {
|
|
36
|
+
const dates = dictionary(), machines = dictionary(), models = dictionary(), projects = dictionary(), sessions = dictionary();
|
|
37
|
+
const agents = dictionary(), skills = dictionary(), mcps = dictionary();
|
|
38
|
+
const key = (r) => [r.d, r.machine, modelLabel(r), r.p, r.s, agentOf(r), r.k ?? '', r.x ?? ''].join('\0');
|
|
39
|
+
const encoded = [...groupBy(rows, key)].map(([k, t]) => {
|
|
40
|
+
const [d, machine, model, p, s, a, sk, x] = k.split('\0');
|
|
41
|
+
const n = (i) => t.t[i] ?? 0;
|
|
42
|
+
return [
|
|
43
|
+
dates.id(d), machines.id(machine), models.id(model), projects.id(p), sessions.id(s), agents.id(a), skills.id(sk), mcps.id(x),
|
|
44
|
+
n(T.calls), r4(t.cost), r4(t.c[0]), r4(t.c[1]), r4(t.c[2]), r4(t.c[3]), r4(t.c[4]),
|
|
45
|
+
contextTokens(t.t), n(T.read), n(T.output), n(T.maxContext), n(T.ctx50k), n(T.ctx200k), n(T.ctx500k), n(T.ctxOver500k),
|
|
46
|
+
];
|
|
47
|
+
});
|
|
48
|
+
const clock = localClock(timezone);
|
|
49
|
+
const hits = limits.filter((l) => l.status === 'rejected').map((l) => {
|
|
50
|
+
const k = clock(l.ts);
|
|
51
|
+
return [k.d, `${String(k.h).padStart(2, '0')}:${String(k.min).padStart(2, '0')}`, l.machine, l.type];
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
dates: dates.values, machines: machines.values, models: models.values, projects: projects.values, sessions: sessions.values,
|
|
55
|
+
agents: agents.values, skills: skills.values, mcps: mcps.values, rows: encoded, hits,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function writeDashboard(d, meta, file) {
|
|
59
|
+
const D = (LANGS[meta.lang] ?? LANGS.en).dash;
|
|
60
|
+
// Server-side text for the chosen language; the page swaps it when the language changes
|
|
61
|
+
const text = (key) => `data-t="${key}">${esc(String(D[key]))}`;
|
|
62
|
+
const strings = Object.fromEntries(Object.entries(LANGS).map(([k, v]) => [k, v.dash]));
|
|
63
|
+
const html = `<!doctype html>
|
|
64
|
+
<html lang="${esc(meta.lang)}"><head><meta charset="utf-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
66
|
+
<title>cc-cost</title>
|
|
67
|
+
<style>
|
|
68
|
+
:root{
|
|
69
|
+
color-scheme:light;
|
|
70
|
+
--plane:#f9f9f7; --surface:#fcfcfb;
|
|
71
|
+
--ink:#0b0b0b; --ink-2:#52514e; --muted:#898781;
|
|
72
|
+
--grid:#e1e0d9; --axis:#c3c2b7; --ring:rgba(11,11,11,.10);
|
|
73
|
+
--hue-1:#2a78d6;--hue-2:#eb6834;--hue-3:#1baf7a;--hue-4:#eda100;
|
|
74
|
+
--hue-5:#e87ba4;--hue-6:#008300;--hue-7:#4a3aa7;--hue-8:#e34948;
|
|
75
|
+
}
|
|
76
|
+
@media (prefers-color-scheme:dark){:root:where(:not([data-theme="light"])){
|
|
77
|
+
color-scheme:dark;
|
|
78
|
+
--plane:#0d0d0d; --surface:#1a1a19;
|
|
79
|
+
--ink:#fff; --ink-2:#c3c2b7; --muted:#898781;
|
|
80
|
+
--grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10);
|
|
81
|
+
--hue-1:#3987e5;--hue-2:#d95926;--hue-3:#199e70;--hue-4:#c98500;
|
|
82
|
+
--hue-5:#d55181;--hue-6:#008300;--hue-7:#9085e9;--hue-8:#e66767;
|
|
83
|
+
}}
|
|
84
|
+
*{box-sizing:border-box}
|
|
85
|
+
body{margin:0;background:var(--plane);color:var(--ink);
|
|
86
|
+
font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;padding:32px 16px 64px}
|
|
87
|
+
.wrap{max-width:1080px;margin:0 auto}
|
|
88
|
+
.site{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;
|
|
89
|
+
padding:0 0 16px;margin:0 0 20px;border-bottom:1px solid var(--grid)}
|
|
90
|
+
.logo{display:flex;align-items:center;gap:10px;color:var(--ink);text-decoration:none}
|
|
91
|
+
.logo svg{width:32px;height:32px}
|
|
92
|
+
.logo b{font-size:20px;font-weight:700;letter-spacing:-.01em}
|
|
93
|
+
.logo small{color:var(--muted);font-size:12px;font-weight:500}
|
|
94
|
+
.links{display:flex;align-items:center;gap:4px}
|
|
95
|
+
.links a{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;color:var(--ink-2)}
|
|
96
|
+
.links a:hover{background:var(--surface);color:var(--ink)}
|
|
97
|
+
.links a:focus-visible{outline:2px solid var(--ink);outline-offset:1px}
|
|
98
|
+
.links a svg{width:18px;height:18px}
|
|
99
|
+
.links .langs{margin-left:8px}
|
|
100
|
+
h1{font-size:15px;font-weight:600;margin:0 0 2px}
|
|
101
|
+
.sub{color:var(--muted);font-size:13px;margin:0 0 24px}
|
|
102
|
+
.foot{color:var(--muted);font-size:12px;text-align:center;margin:24px 0 0}
|
|
103
|
+
.foot a{color:inherit}
|
|
104
|
+
.langs{display:flex;gap:4px}
|
|
105
|
+
.langs button{font:inherit;font-size:12px;padding:3px 9px;border-radius:6px;border:1px solid var(--ring);
|
|
106
|
+
background:var(--surface);color:var(--ink-2);cursor:pointer}
|
|
107
|
+
.langs button[aria-pressed="true"]{background:var(--ink);color:var(--surface);border-color:var(--ink)}
|
|
108
|
+
|
|
109
|
+
.filters{display:flex;gap:6px;align-items:center;margin:0 0 12px;flex-wrap:wrap}
|
|
110
|
+
.filters.last{margin-bottom:24px}
|
|
111
|
+
.filters button{font:inherit;font-size:13px;padding:6px 14px;border-radius:999px;
|
|
112
|
+
border:1px solid var(--ring);background:var(--surface);color:var(--ink-2);cursor:pointer}
|
|
113
|
+
.filters button:hover{background:var(--plane)}
|
|
114
|
+
.filters button[aria-pressed="true"]{background:var(--ink);color:var(--surface);border-color:var(--ink);font-weight:600}
|
|
115
|
+
.fsep{width:1px;height:22px;background:var(--grid);margin:0 6px}
|
|
116
|
+
.range{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:13px}
|
|
117
|
+
.range input,.filters select{font:inherit;font-size:13px;color:var(--ink-2);background:var(--surface);
|
|
118
|
+
border:1px solid var(--ring);border-radius:8px;padding:5px 9px;max-width:220px}
|
|
119
|
+
.range input:focus,.filters select:focus{outline:2px solid var(--ink);outline-offset:1px}
|
|
120
|
+
.range[data-on="1"] input,.filters select.on{color:var(--ink);border-color:var(--ink);font-weight:600}
|
|
121
|
+
.flabel{color:var(--muted);font-size:12px}
|
|
122
|
+
.reset{display:none}
|
|
123
|
+
.range[data-on="1"] ~ .reset{display:inline-block}
|
|
124
|
+
|
|
125
|
+
.card{background:var(--surface);border:1px solid var(--ring);border-radius:10px;padding:20px 22px;margin:0 0 16px;min-width:0}
|
|
126
|
+
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin:0 0 16px}
|
|
127
|
+
.grid2 .card{margin:0}
|
|
128
|
+
.hero-label{color:var(--muted);font-size:13px;margin:0 0 6px}
|
|
129
|
+
.hero{font-size:52px;font-weight:600;letter-spacing:-.02em;line-height:1.05;margin:0}
|
|
130
|
+
.hero-note{color:var(--ink-2);font-size:13px;margin:8px 0 0}
|
|
131
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin:22px 0 0;
|
|
132
|
+
padding:20px 0 0;border-top:1px solid var(--grid)}
|
|
133
|
+
.tile-l{color:var(--muted);font-size:12px;margin:0 0 4px}
|
|
134
|
+
.tile-v{font-size:22px;font-weight:600;margin:0}
|
|
135
|
+
.tile-n{color:var(--muted);font-size:12px;margin:3px 0 0}
|
|
136
|
+
|
|
137
|
+
h2{font-size:13px;font-weight:600;margin:0 0 2px}
|
|
138
|
+
h2 + .scroll,h2 + div{margin-top:14px}
|
|
139
|
+
.h2sub{color:var(--muted);font-size:12px;margin:0 0 16px}
|
|
140
|
+
.legend{display:flex;gap:16px;flex-wrap:wrap;margin:14px 0 0}
|
|
141
|
+
.legend span{display:inline-flex;align-items:center;gap:6px;color:var(--ink-2);font-size:12px}
|
|
142
|
+
.sw{width:10px;height:10px;border-radius:2px;flex:none}
|
|
143
|
+
|
|
144
|
+
.scroll{overflow-x:auto}
|
|
145
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
146
|
+
th{text-align:left;font-weight:500;color:var(--muted);font-size:12px;
|
|
147
|
+
padding:0 10px 8px 0;border-bottom:1px solid var(--grid);white-space:nowrap}
|
|
148
|
+
td{padding:8px 10px 8px 0;border-bottom:1px solid var(--grid);font-variant-numeric:tabular-nums}
|
|
149
|
+
td:first-child,th:first-child{font-variant-numeric:normal}
|
|
150
|
+
tr:last-child td{border-bottom:0}
|
|
151
|
+
.num{text-align:right;white-space:nowrap}
|
|
152
|
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
|
|
153
|
+
.key{display:inline-flex;align-items:center;gap:8px}
|
|
154
|
+
|
|
155
|
+
#tip{position:fixed;pointer-events:none;opacity:0;transition:opacity .1s;z-index:9;
|
|
156
|
+
background:var(--surface);border:1px solid var(--ring);border-radius:8px;padding:10px 12px;
|
|
157
|
+
box-shadow:0 4px 16px rgba(0,0,0,.12);min-width:150px}
|
|
158
|
+
#tip .d{color:var(--muted);font-size:12px;margin:0 0 6px}
|
|
159
|
+
#tip .r{display:flex;align-items:center;gap:8px;justify-content:space-between;margin:3px 0}
|
|
160
|
+
#tip .n{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
161
|
+
#tip .m{color:var(--ink-2);font-size:12px;display:inline-flex;align-items:center;gap:6px}
|
|
162
|
+
#tip .k{width:12px;height:2px;border-radius:1px;flex:none}
|
|
163
|
+
svg{display:block;width:100%;height:auto;overflow:visible}
|
|
164
|
+
.band:focus{outline:2px solid var(--ink);outline-offset:1px}
|
|
165
|
+
.empty{color:var(--muted);font-size:13px;padding:12px 0}
|
|
166
|
+
</style></head><body>
|
|
167
|
+
<div class="wrap">
|
|
168
|
+
<header class="site">
|
|
169
|
+
<a class="logo" href="${esc(meta.repo)}" target="_blank" rel="noopener">${ICON.logo}<b>cc-cost</b><small>v${esc(meta.version)}</small></a>
|
|
170
|
+
<nav class="links">
|
|
171
|
+
<a href="${esc(meta.repo)}" target="_blank" rel="noopener" title="GitHub" aria-label="GitHub">${ICON.github}</a>
|
|
172
|
+
<a href="${esc(meta.npm)}" target="_blank" rel="noopener" title="npm" aria-label="npm">${ICON.npm}</a>
|
|
173
|
+
<a href="${esc(meta.site)}" target="_blank" rel="noopener" title="${esc(D.website)}" aria-label="${esc(D.website)}" data-t-title="website" data-t-aria="website">${ICON.globe}</a>
|
|
174
|
+
<div class="langs" role="group" aria-label="${esc(D.language)}" data-t-aria="language">
|
|
175
|
+
${Object.keys(LANGS).map((l) => `<button data-lang="${l}">${l.toUpperCase()}</button>`).join('')}
|
|
176
|
+
</div>
|
|
177
|
+
</nav>
|
|
178
|
+
</header>
|
|
179
|
+
<h1 ${text('title')}</h1>
|
|
180
|
+
<p class="sub" id="note"></p>
|
|
181
|
+
|
|
182
|
+
<div class="filters" role="group" aria-label="${esc(D.timeRange)}" data-t-aria="timeRange">
|
|
183
|
+
<button data-days="1" ${text('today')}</button>
|
|
184
|
+
<button data-days="7" ${text('last7')}</button>
|
|
185
|
+
<button data-days="30" aria-pressed="true" ${text('last30')}</button>
|
|
186
|
+
<button data-days="90" ${text('last90')}</button>
|
|
187
|
+
<button data-days="0" ${text('all')}</button>
|
|
188
|
+
<span class="fsep"></span>
|
|
189
|
+
<span class="range" id="range">
|
|
190
|
+
<input type="date" id="from" aria-label="${esc(D.from)}" data-t-aria="from">
|
|
191
|
+
<span aria-hidden="true">→</span>
|
|
192
|
+
<input type="date" id="to" aria-label="${esc(D.to)}" data-t-aria="to">
|
|
193
|
+
</span>
|
|
194
|
+
<button class="reset" id="reset" ${text('clear')}</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="filters last">
|
|
197
|
+
<select id="fMachine" aria-label="${esc(D.machine)}" data-t-aria="machine"></select>
|
|
198
|
+
<select id="fProject" aria-label="${esc(D.project)}" data-t-aria="project"></select>
|
|
199
|
+
<select id="fModel" aria-label="${esc(D.model)}" data-t-aria="model"></select>
|
|
200
|
+
<span class="fsep"></span>
|
|
201
|
+
<label class="flabel" for="groupBy" ${text('groupBy')}</label>
|
|
202
|
+
<select id="groupBy">
|
|
203
|
+
<option value="model" ${text('byModelOpt')}</option>
|
|
204
|
+
<option value="project" ${text('byProjectOpt')}</option>
|
|
205
|
+
<option value="machine" ${text('byMachineOpt')}</option>
|
|
206
|
+
</select>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="card">
|
|
210
|
+
<p class="hero-label" id="heroLabel"></p>
|
|
211
|
+
<p class="hero" id="hero">$0</p>
|
|
212
|
+
<p class="hero-note" ${text('heroNote')}</p>
|
|
213
|
+
<div class="tiles">
|
|
214
|
+
<div><p class="tile-l" ${text('dailyAvg')}</p><p class="tile-v" id="tAvg">–</p><p class="tile-n" id="tAvgN"></p></div>
|
|
215
|
+
<div><p class="tile-l" id="tMultL"></p><p class="tile-v" id="tMult">–</p><p class="tile-n" id="tMultN"></p></div>
|
|
216
|
+
<div><p class="tile-l" ${text('apiCalls')}</p><p class="tile-v" id="tCalls">–</p><p class="tile-n" id="tCallsN"></p></div>
|
|
217
|
+
<div><p class="tile-l" ${text('cacheHit')}</p><p class="tile-v" id="tHit">–</p><p class="tile-n" ${text('cacheHitNote')}</p></div>
|
|
218
|
+
<div><p class="tile-l" ${text('avgContext')}</p><p class="tile-v" id="tCtx">–</p><p class="tile-n" ${text('avgContextNote')}</p></div>
|
|
219
|
+
<div><p class="tile-l" ${text('machines')}</p><p class="tile-v" id="tHosts">–</p><p class="tile-n" id="tHostsN"></p></div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="card">
|
|
224
|
+
<h2 ${text('dailyCost')}</h2>
|
|
225
|
+
<p class="h2sub" ${text('dailyCostSub')}</p>
|
|
226
|
+
<div id="chart"></div>
|
|
227
|
+
<div class="legend" id="legend"></div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div class="grid2">
|
|
231
|
+
<div class="card">
|
|
232
|
+
<h2 ${text('whereCost')}</h2>
|
|
233
|
+
<p class="h2sub" ${text('whereCostSub')}</p>
|
|
234
|
+
<div id="items"></div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="card">
|
|
237
|
+
<h2 ${text('contextTitle')}</h2>
|
|
238
|
+
<p class="h2sub" ${text('contextSub')}</p>
|
|
239
|
+
<div id="buckets"></div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div class="grid2">
|
|
244
|
+
<div class="card"><h2 ${text('byModel')}</h2><div class="scroll"><table><thead><tr id="hModels"></tr></thead><tbody id="tModels"></tbody></table></div></div>
|
|
245
|
+
<div class="card"><h2 ${text('byProject')}</h2><div class="scroll"><table><thead><tr id="hProjects"></tr></thead><tbody id="tProjects"></tbody></table></div></div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div class="card">
|
|
249
|
+
<h2 ${text('sessions')}</h2>
|
|
250
|
+
<p class="h2sub" ${text('sessionsSub')}</p>
|
|
251
|
+
<div class="scroll"><table><thead><tr id="hSessions"></tr></thead><tbody id="tSessions"></tbody></table></div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div class="grid2">
|
|
255
|
+
<div class="card"><h2 ${text('agents')}</h2><div class="scroll"><table><thead><tr id="hAgents"></tr></thead><tbody id="tAgents"></tbody></table></div></div>
|
|
256
|
+
<div class="card"><h2 ${text('skills')}</h2><div class="scroll"><table><thead><tr id="hSkills"></tr></thead><tbody id="tSkills"></tbody></table></div></div>
|
|
257
|
+
<div class="card"><h2 ${text('mcp')}</h2><div class="scroll"><table><thead><tr id="hMcp"></tr></thead><tbody id="tMcp"></tbody></table></div></div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div class="grid2">
|
|
261
|
+
<div class="card"><h2 ${text('byMachine')}</h2><div class="scroll"><table><thead><tr id="hMachines"></tr></thead><tbody id="tMachines"></tbody></table></div></div>
|
|
262
|
+
<div class="card"><h2 ${text('limits')}</h2><div id="limits"></div></div>
|
|
263
|
+
</div>
|
|
264
|
+
<p class="foot"><span id="footText"></span> · <a href="${esc(meta.repo)}" target="_blank" rel="noopener">GitHub</a></p>
|
|
265
|
+
</div>
|
|
266
|
+
<div id="tip" role="status" aria-live="polite"></div>
|
|
267
|
+
|
|
268
|
+
<script>
|
|
269
|
+
const DATA = ${js(encode(d.rows, d.limits, meta.timezone))};
|
|
270
|
+
const META = ${js(meta)};
|
|
271
|
+
const STRINGS = ${js(strings)};
|
|
272
|
+
const ORDER = ${js(ORDER)};
|
|
273
|
+
const UNKNOWN = ${js(UNKNOWN)};
|
|
274
|
+
// Row layout, same order as encode() in dashboard.ts
|
|
275
|
+
const R = { d: 0, machine: 1, model: 2, project: 3, session: 4, agent: 5, skill: 6, mcp: 7, calls: 8, cost: 9,
|
|
276
|
+
ci: 10, cw: 11, cr: 12, co: 13, cx: 14, ctx: 15, read: 16, out: 17, maxCtx: 18, b0: 19 };
|
|
277
|
+
const ROWS = DATA.rows.map(r => ({
|
|
278
|
+
d: DATA.dates[r[R.d]], machine: DATA.machines[r[R.machine]], model: DATA.models[r[R.model]], project: DATA.projects[r[R.project]],
|
|
279
|
+
session: DATA.sessions[r[R.session]], agent: DATA.agents[r[R.agent]], skill: DATA.skills[r[R.skill]], mcp: DATA.mcps[r[R.mcp]],
|
|
280
|
+
calls: r[R.calls], c: r[R.cost], ci: r[R.ci], cw: r[R.cw], cr: r[R.cr], co: r[R.co], cx: r[R.cx],
|
|
281
|
+
ctx: r[R.ctx], read: r[R.read], out: r[R.out], maxCtx: r[R.maxCtx], b: r.slice(R.b0, R.b0 + 4)
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
let lang = META.lang;
|
|
285
|
+
try { const saved = localStorage.getItem('cc-cost-lang'); if (saved && STRINGS[saved]) lang = saved; } catch (e) {}
|
|
286
|
+
let L = STRINGS[lang] || STRINGS.en;
|
|
287
|
+
|
|
288
|
+
const fill = (s, v) => s.replace(/\\{(\\w+)\\}/g, (_, k) => v[k] ?? '{' + k + '}');
|
|
289
|
+
const $ = (n) => '$' + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
|
|
290
|
+
const $$ = (n) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
291
|
+
// Same separators as $ in every language: '.' as thousands separator next to '$' reads as decimals
|
|
292
|
+
const NF = new Intl.NumberFormat('en-US');
|
|
293
|
+
const tok = (n) => n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(Math.round(n));
|
|
294
|
+
const pct = (a, b) => (b ? a / b * 100 : 0).toFixed(1) + '%';
|
|
295
|
+
const DAY = 86400000;
|
|
296
|
+
// Calendar day in the zone the snapshots were split by
|
|
297
|
+
const iso = (t) => new Date(t).toLocaleDateString('sv-SE', { timeZone: META.timezone });
|
|
298
|
+
const hue = (i) => 'var(--hue-' + i + ')';
|
|
299
|
+
const modelHue = (m) => hue(ORDER.indexOf(m) < 0 ? 8 : ORDER.indexOf(m) + 1);
|
|
300
|
+
|
|
301
|
+
const ALL = [...new Set(ROWS.map(r => r.d))].sort();
|
|
302
|
+
const MIN = ALL[0] || iso(Date.now()), MAX = ALL[ALL.length - 1] || iso(Date.now());
|
|
303
|
+
|
|
304
|
+
let days = 30; // preset: 1 = today, 0 = all
|
|
305
|
+
let custom = null; // {from, to}; overrides the preset while set
|
|
306
|
+
const sel = { machine: '', project: '', model: '' };
|
|
307
|
+
let groupBy = 'model';
|
|
308
|
+
|
|
309
|
+
// Calendar days: "last 7 days" means the last 7 calendar days, not the last 7 days with data
|
|
310
|
+
function bounds() {
|
|
311
|
+
if (custom) return custom;
|
|
312
|
+
if (!days) return { from: MIN, to: MAX };
|
|
313
|
+
return { from: iso(Date.now() - (days - 1) * DAY), to: iso(Date.now()) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function slice() {
|
|
317
|
+
const b = bounds();
|
|
318
|
+
return ROWS.filter(r => r.d >= b.from && r.d <= b.to &&
|
|
319
|
+
(!sel.machine || r.machine === sel.machine) && (!sel.project || r.project === sel.project) && (!sel.model || r.model === sel.model));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The plan multiple is per calendar month, so divide by calendar days, not active days
|
|
323
|
+
// (that would assume you work every day and inflate it). Clip the window to where data exists
|
|
324
|
+
// so empty edges don't pull the ratio down.
|
|
325
|
+
function calendarSpan(dates) {
|
|
326
|
+
if (!dates.length) return 1;
|
|
327
|
+
const b = bounds();
|
|
328
|
+
const from = Math.max(Date.parse(b.from), Date.parse(dates[0]));
|
|
329
|
+
const to = Math.min(Date.parse(b.to), Date.parse(dates[dates.length - 1]));
|
|
330
|
+
return Math.max(1, Math.round((to - from) / DAY) + 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function rangeLabel(dates) {
|
|
334
|
+
if (custom) return custom.from + ' → ' + custom.to;
|
|
335
|
+
if (!days) return fill(L.allRecords, { n: dates.length });
|
|
336
|
+
return days === 1 ? L.today : fill(L.lastDays, { n: days });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sumBy(rs, key) {
|
|
340
|
+
const m = new Map();
|
|
341
|
+
for (const r of rs) {
|
|
342
|
+
const k = key(r);
|
|
343
|
+
const a = m.get(k) || { c: 0, calls: 0, ctx: 0, days: new Set(), machines: new Set(), project: r.project };
|
|
344
|
+
a.c += r.c; a.calls += r.calls; a.ctx += r.ctx;
|
|
345
|
+
a.days.add(r.d); a.machines.add(r.machine);
|
|
346
|
+
m.set(k, a);
|
|
347
|
+
}
|
|
348
|
+
return [...m].sort((x, y) => y[1].c - x[1].c);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Series for the stacked chart. Models keep their fixed colors; projects and machines take the
|
|
352
|
+
// first seven colors by cost in the current selection, the rest fold into "other".
|
|
353
|
+
const OTHER = '__cc-cost-other__';
|
|
354
|
+
function series(rs) {
|
|
355
|
+
if (groupBy === 'model') {
|
|
356
|
+
const present = new Set(rs.map(r => r.model));
|
|
357
|
+
const list = [...ORDER.filter(m => present.has(m)), ...[...present].filter(m => !ORDER.includes(m)).sort()];
|
|
358
|
+
return { key: r => r.model, list, hue: modelHue, label: m => m };
|
|
359
|
+
}
|
|
360
|
+
const key = groupBy === 'project' ? (r => r.project) : (r => r.machine);
|
|
361
|
+
const ranked = sumBy(rs, key).map(e => e[0]);
|
|
362
|
+
const top = ranked.slice(0, 7);
|
|
363
|
+
const list = ranked.length > 7 ? [...top, OTHER] : top;
|
|
364
|
+
return { key: r => top.includes(key(r)) ? key(r) : OTHER, list, hue: k => hue(k === OTHER ? 8 : top.indexOf(k) + 1), label: k => k === OTHER ? L.other : k };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function fillSelect(id, values, all, current) {
|
|
368
|
+
const s = document.getElementById(id);
|
|
369
|
+
s.textContent = '';
|
|
370
|
+
s.append(new Option(all, ''));
|
|
371
|
+
for (const v of [...values].sort()) s.append(new Option(v, v, false, v === current));
|
|
372
|
+
s.classList.toggle('on', !!current);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function applyText() {
|
|
376
|
+
document.documentElement.lang = lang;
|
|
377
|
+
for (const e of document.querySelectorAll('[data-t]')) e.textContent = L[e.dataset.t];
|
|
378
|
+
for (const e of document.querySelectorAll('[data-t-aria]')) e.setAttribute('aria-label', L[e.dataset.tAria]);
|
|
379
|
+
for (const e of document.querySelectorAll('[data-t-title]')) e.title = L[e.dataset.tTitle];
|
|
380
|
+
for (const b of document.querySelectorAll('[data-lang]')) b.setAttribute('aria-pressed', String(b.dataset.lang === lang));
|
|
381
|
+
const updated = new Date(META.updated).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-US', { timeZone: META.timezone });
|
|
382
|
+
document.getElementById('note').textContent = fill(L.note, { updated, machines: META.machines, min: META.first || '–', max: META.last || '–', prices: META.prices });
|
|
383
|
+
document.getElementById('footText').textContent = fill(L.footer, { version: META.version });
|
|
384
|
+
fillSelect('fMachine', DATA.machines, L.allMachines, sel.machine);
|
|
385
|
+
fillSelect('fProject', DATA.projects, L.allProjects, sel.project);
|
|
386
|
+
fillSelect('fModel', DATA.models, L.allModels, sel.model);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function render() {
|
|
390
|
+
const rs = slice();
|
|
391
|
+
const total = rs.reduce((a, r) => a + r.c, 0);
|
|
392
|
+
const dates = [...new Set(rs.map(r => r.d))].sort();
|
|
393
|
+
const calls = rs.reduce((a, r) => a + r.calls, 0);
|
|
394
|
+
const ctx = rs.reduce((a, r) => a + r.ctx, 0);
|
|
395
|
+
const read = rs.reduce((a, r) => a + r.read, 0);
|
|
396
|
+
const hosts = [...new Set(rs.map(r => r.machine))];
|
|
397
|
+
|
|
398
|
+
document.getElementById('heroLabel').textContent = rangeLabel(dates);
|
|
399
|
+
document.getElementById('hero').textContent = $$(total);
|
|
400
|
+
const active = dates.length;
|
|
401
|
+
const span = calendarSpan(dates);
|
|
402
|
+
document.getElementById('tAvg').textContent = active ? $$(total / active) : '–';
|
|
403
|
+
document.getElementById('tAvgN').textContent = fill(L.activeDays, { n: active });
|
|
404
|
+
document.getElementById('tMultL').textContent = fill(L.planMultiple, { price: '$' + META.planPrice });
|
|
405
|
+
document.getElementById('tMult').textContent = active ? (total / (META.planPrice * span / 30)).toFixed(1) + '×' : '–';
|
|
406
|
+
document.getElementById('tMultN').textContent = active ? fill(L.normalized, { n: span }) : '';
|
|
407
|
+
document.getElementById('tCalls').textContent = NF.format(calls);
|
|
408
|
+
document.getElementById('tCallsN').textContent = fill(L.perCall, { cost: $$(total / (calls || 1)) });
|
|
409
|
+
document.getElementById('tHit').textContent = ctx ? pct(read, ctx) : '–';
|
|
410
|
+
document.getElementById('tCtx').textContent = calls ? tok(ctx / calls) : '–';
|
|
411
|
+
document.getElementById('tHosts').textContent = hosts.length;
|
|
412
|
+
document.getElementById('tHostsN').textContent = hosts.join(', ');
|
|
413
|
+
|
|
414
|
+
const s = series(rs);
|
|
415
|
+
drawDaily(rs, dates, s);
|
|
416
|
+
drawItems(rs);
|
|
417
|
+
drawBuckets(rs);
|
|
418
|
+
const legend = document.getElementById('legend');
|
|
419
|
+
legend.textContent = '';
|
|
420
|
+
for (const k of s.list) {
|
|
421
|
+
const e = document.createElement('span');
|
|
422
|
+
const sw = document.createElement('i'); sw.className = 'sw'; sw.style.background = s.hue(k);
|
|
423
|
+
e.append(sw, document.createTextNode(s.label(k)));
|
|
424
|
+
legend.append(e);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const cols = [[L.cost, 'num'], [L.share, 'num'], [L.calls, 'num'], [L.avgCtx, 'num']];
|
|
428
|
+
const nums = (g) => [$$(g.c), pct(g.c, total), NF.format(g.calls), g.calls ? tok(g.ctx / g.calls) : '–'];
|
|
429
|
+
table('Models', [[L.model], ...cols], sumBy(rs, r => r.model).map(([k, g]) => [swatch(k, modelHue(k)), ...nums(g)]));
|
|
430
|
+
table('Projects', [[L.project], ...cols], sumBy(rs, r => r.project).map(([k, g]) => [k, ...nums(g)]));
|
|
431
|
+
table('Machines', [[L.machine], ...cols], sumBy(rs, r => r.machine).map(([k, g]) => [k, ...nums(g)]));
|
|
432
|
+
table('Sessions', [[L.session], [L.project], [L.machine], [L.first], [L.last], [L.calls, 'num'], [L.avgCtx, 'num'], [L.cost, 'num']],
|
|
433
|
+
sumBy(rs.filter(r => r.session !== UNKNOWN), r => r.session).slice(0, 15).map(([k, g]) => {
|
|
434
|
+
const d = [...g.days].sort();
|
|
435
|
+
return [mono(k.slice(0, 8)), g.project, [...g.machines].join(', '), d[0], d[d.length - 1], NF.format(g.calls), tok(g.ctx / (g.calls || 1)), $$(g.c)];
|
|
436
|
+
}));
|
|
437
|
+
const small = [[L.cost, 'num'], [L.share, 'num'], [L.calls, 'num']];
|
|
438
|
+
const smallRow = (label, g) => [label, $$(g.c), pct(g.c, total), NF.format(g.calls)];
|
|
439
|
+
table('Agents', [[L.agentCol], ...small], sumBy(rs, r => r.agent).map(([k, g]) => smallRow(k === 'main' ? L.main : k, g)));
|
|
440
|
+
table('Skills', [[L.skillCol], ...small], sumBy(rs.filter(r => r.skill), r => r.skill).slice(0, 10).map(([k, g]) => smallRow(k, g)));
|
|
441
|
+
table('Mcp', [[L.mcpCol], ...small], sumBy(rs.filter(r => r.mcp), r => r.mcp).slice(0, 10).map(([k, g]) => smallRow(k, g)));
|
|
442
|
+
drawLimits();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function swatch(text, color) {
|
|
446
|
+
const k = document.createElement('span'); k.className = 'key';
|
|
447
|
+
const sw = document.createElement('i'); sw.className = 'sw'; sw.style.background = color;
|
|
448
|
+
k.append(sw, document.createTextNode(text));
|
|
449
|
+
return k;
|
|
450
|
+
}
|
|
451
|
+
function mono(text) { const s = document.createElement('span'); s.className = 'mono'; s.textContent = text; return s; }
|
|
452
|
+
|
|
453
|
+
function table(id, head, rows) {
|
|
454
|
+
const h = document.getElementById('h' + id); h.textContent = '';
|
|
455
|
+
for (const [t, cls] of head) { const th = document.createElement('th'); th.textContent = t; if (cls) th.className = cls; h.append(th); }
|
|
456
|
+
const b = document.getElementById('t' + id); b.textContent = '';
|
|
457
|
+
if (!rows.length) {
|
|
458
|
+
const tr = document.createElement('tr'), td = document.createElement('td');
|
|
459
|
+
td.colSpan = head.length; td.className = 'empty'; td.textContent = '–'; tr.append(td); b.append(tr);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
for (const row of rows) {
|
|
463
|
+
const tr = document.createElement('tr');
|
|
464
|
+
row.forEach((v, i) => {
|
|
465
|
+
const td = document.createElement('td');
|
|
466
|
+
if (head[i][1]) td.className = head[i][1];
|
|
467
|
+
if (v instanceof Node) td.append(v); else td.textContent = v;
|
|
468
|
+
tr.append(td);
|
|
469
|
+
});
|
|
470
|
+
b.append(tr);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function emptyNote() {
|
|
475
|
+
const p = document.createElement('p'); p.className = 'empty';
|
|
476
|
+
p.textContent = fill(L.empty, { min: MIN, max: MAX });
|
|
477
|
+
return p;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
481
|
+
const el = (t, a) => { const e = document.createElementNS(SVGNS, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
|
|
482
|
+
|
|
483
|
+
function drawDaily(rs, dates, s) {
|
|
484
|
+
const host = document.getElementById('chart');
|
|
485
|
+
host.textContent = '';
|
|
486
|
+
if (!dates.length) { host.append(emptyNote()); return; }
|
|
487
|
+
|
|
488
|
+
const W = 1000, H = 260, ml = 52, mr = 8, mt = 12, mb = 28;
|
|
489
|
+
const pw = W - ml - mr, ph = H - mt - mb;
|
|
490
|
+
const byDate = {};
|
|
491
|
+
for (const r of rs) { const k = s.key(r); (byDate[r.d] ??= {})[k] = (byDate[r.d][k] || 0) + r.c; }
|
|
492
|
+
const max = Math.max(...dates.map(d => Object.values(byDate[d]).reduce((a, b) => a + b, 0)), 0.01);
|
|
493
|
+
|
|
494
|
+
const mag = Math.pow(10, Math.floor(Math.log10(max)));
|
|
495
|
+
const step = mag * (max / mag > 5 ? 2 : 1);
|
|
496
|
+
const top = Math.ceil(max / step) * step;
|
|
497
|
+
const y = (v) => mt + ph - (v / top) * ph;
|
|
498
|
+
|
|
499
|
+
const svg = el('svg', { viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': L.chartLabel });
|
|
500
|
+
for (let v = 0; v <= top + 1e-9; v += step) {
|
|
501
|
+
svg.append(el('line', { x1: ml, x2: W - mr, y1: y(v), y2: y(v), stroke: 'var(--grid)', 'stroke-width': 1 }));
|
|
502
|
+
const t = el('text', { x: ml - 10, y: y(v) + 4, 'text-anchor': 'end', fill: 'var(--muted)', 'font-size': 11,
|
|
503
|
+
style: 'font-variant-numeric:tabular-nums' });
|
|
504
|
+
t.textContent = $(v); svg.append(t);
|
|
505
|
+
}
|
|
506
|
+
svg.append(el('line', { x1: ml, x2: W - mr, y1: y(0), y2: y(0), stroke: 'var(--axis)', 'stroke-width': 1 }));
|
|
507
|
+
|
|
508
|
+
const band = pw / dates.length;
|
|
509
|
+
const bw = Math.min(24, Math.max(3, band - 4));
|
|
510
|
+
const every = Math.ceil(dates.length / 12);
|
|
511
|
+
|
|
512
|
+
dates.forEach((d, i) => {
|
|
513
|
+
const cx = ml + band * i + band / 2;
|
|
514
|
+
let acc = 0;
|
|
515
|
+
const stack = s.list.filter(k => byDate[d][k] > 0);
|
|
516
|
+
stack.forEach((k, j) => {
|
|
517
|
+
const v = byDate[d][k];
|
|
518
|
+
const y0 = y(acc), y1 = y(acc + v);
|
|
519
|
+
const gap = j < stack.length - 1 ? 2 : 0;
|
|
520
|
+
const h = Math.max(1, y0 - y1 - gap);
|
|
521
|
+
const isTop = j === stack.length - 1;
|
|
522
|
+
const r = Math.min(4, h, bw / 2);
|
|
523
|
+
const x = cx - bw / 2, yy = y1 + (isTop ? 0 : gap);
|
|
524
|
+
const p = isTop
|
|
525
|
+
? 'M' + x + ',' + (yy + h) + 'V' + (yy + r) + 'a' + r + ',' + r + ' 0 0 1 ' + r + ',' + (-r) +
|
|
526
|
+
'h' + (bw - 2 * r) + 'a' + r + ',' + r + ' 0 0 1 ' + r + ',' + r + 'V' + (yy + h) + 'Z'
|
|
527
|
+
: 'M' + x + ',' + yy + 'h' + bw + 'v' + h + 'h' + (-bw) + 'Z';
|
|
528
|
+
svg.append(el('path', { d: p, fill: s.hue(k) }));
|
|
529
|
+
acc += v;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (i % every === 0) {
|
|
533
|
+
const t = el('text', { x: cx, y: H - 8, 'text-anchor': 'middle', fill: 'var(--muted)', 'font-size': 11 });
|
|
534
|
+
t.textContent = d.slice(5).replace('-', '.'); svg.append(t);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const hit = el('rect', { x: ml + band * i, y: mt, width: band, height: ph, fill: 'transparent', class: 'band', tabindex: 0 });
|
|
538
|
+
const show = (ev) => {
|
|
539
|
+
const b = hit.getBoundingClientRect();
|
|
540
|
+
tip(ev ? ev.clientX : b.x + b.width / 2, ev ? ev.clientY : b.y + 20, d, byDate[d], stack, s);
|
|
541
|
+
};
|
|
542
|
+
hit.addEventListener('pointermove', show);
|
|
543
|
+
hit.addEventListener('focus', () => show(null));
|
|
544
|
+
hit.addEventListener('pointerleave', hide);
|
|
545
|
+
hit.addEventListener('blur', hide);
|
|
546
|
+
svg.append(hit);
|
|
547
|
+
});
|
|
548
|
+
host.append(svg);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const TIP = document.getElementById('tip');
|
|
552
|
+
function tip(x, y, date, vals, keys, s) {
|
|
553
|
+
TIP.textContent = '';
|
|
554
|
+
const h = document.createElement('p'); h.className = 'd'; h.textContent = date; TIP.append(h);
|
|
555
|
+
let sum = 0;
|
|
556
|
+
for (const k of [...keys].reverse()) {
|
|
557
|
+
const row = document.createElement('div'); row.className = 'r';
|
|
558
|
+
const lab = document.createElement('span'); lab.className = 'm';
|
|
559
|
+
const sw = document.createElement('i'); sw.className = 'k'; sw.style.background = s.hue(k);
|
|
560
|
+
lab.append(sw, document.createTextNode(s.label(k)));
|
|
561
|
+
const n = document.createElement('span'); n.className = 'n'; n.textContent = $$(vals[k]);
|
|
562
|
+
row.append(lab, n); TIP.append(row); sum += vals[k];
|
|
563
|
+
}
|
|
564
|
+
if (keys.length > 1) {
|
|
565
|
+
const row = document.createElement('div'); row.className = 'r';
|
|
566
|
+
row.style.cssText = 'border-top:1px solid var(--grid);margin-top:6px;padding-top:6px';
|
|
567
|
+
const lab = document.createElement('span'); lab.className = 'm'; lab.textContent = L.total;
|
|
568
|
+
const n = document.createElement('span'); n.className = 'n'; n.textContent = $$(sum);
|
|
569
|
+
row.append(lab, n); TIP.append(row);
|
|
570
|
+
}
|
|
571
|
+
TIP.style.opacity = 1;
|
|
572
|
+
const w = TIP.offsetWidth, hh = TIP.offsetHeight;
|
|
573
|
+
TIP.style.left = Math.min(window.innerWidth - w - 12, Math.max(12, x + 14)) + 'px';
|
|
574
|
+
TIP.style.top = Math.max(12, Math.min(window.innerHeight - hh - 12, y - hh / 2)) + 'px';
|
|
575
|
+
}
|
|
576
|
+
const hide = () => { TIP.style.opacity = 0; };
|
|
577
|
+
|
|
578
|
+
// Horizontal bars with a value label; used by the cost-by-type and context charts
|
|
579
|
+
function bars(hostId, items, label, fmt) {
|
|
580
|
+
const host = document.getElementById(hostId);
|
|
581
|
+
host.textContent = '';
|
|
582
|
+
const total = items.reduce((a, i) => a + i[1], 0);
|
|
583
|
+
if (!total) { host.append(emptyNote()); return; }
|
|
584
|
+
const max = Math.max(...items.map(i => i[1]), 0.01);
|
|
585
|
+
const W = 1000, rowH = 44, ml = 190, mr = 230;
|
|
586
|
+
const svg = el('svg', { viewBox: '0 0 ' + W + ' ' + items.length * rowH, role: 'img', 'aria-label': label });
|
|
587
|
+
items.forEach(([name, v], i) => {
|
|
588
|
+
const cy = i * rowH + rowH / 2, bh = 22, w = Math.max(1, (v / max) * (W - ml - mr));
|
|
589
|
+
const t = el('text', { x: ml - 12, y: cy + 5, 'text-anchor': 'end', fill: 'var(--ink-2)', 'font-size': 18 });
|
|
590
|
+
t.textContent = name; svg.append(t);
|
|
591
|
+
const r = Math.min(4, bh / 2, w);
|
|
592
|
+
svg.append(el('path', {
|
|
593
|
+
d: 'M' + ml + ',' + (cy - bh / 2) + 'h' + (w - r) + 'a' + r + ',' + r + ' 0 0 1 ' + r + ',' + r +
|
|
594
|
+
'v' + (bh - 2 * r) + 'a' + r + ',' + r + ' 0 0 1 ' + (-r) + ',' + r + 'H' + ml + 'Z',
|
|
595
|
+
fill: hue(1)
|
|
596
|
+
}));
|
|
597
|
+
const val = el('text', { x: ml + w + 10, y: cy + 5, fill: 'var(--ink)', 'font-size': 18, 'font-weight': 600,
|
|
598
|
+
style: 'font-variant-numeric:tabular-nums' });
|
|
599
|
+
val.textContent = fmt(v) + ' · ' + (v / total * 100).toFixed(0) + '%';
|
|
600
|
+
svg.append(val);
|
|
601
|
+
});
|
|
602
|
+
host.append(svg);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function drawItems(rs) {
|
|
606
|
+
bars('items', [
|
|
607
|
+
[L.cacheRead, rs.reduce((a, r) => a + r.cr, 0)],
|
|
608
|
+
[L.cacheWrite, rs.reduce((a, r) => a + r.cw, 0)],
|
|
609
|
+
[L.output, rs.reduce((a, r) => a + r.co, 0)],
|
|
610
|
+
[L.input, rs.reduce((a, r) => a + r.ci, 0)],
|
|
611
|
+
[L.web, rs.reduce((a, r) => a + r.cx, 0)]
|
|
612
|
+
].filter(i => i[1] > 0).sort((a, b) => b[1] - a[1]), L.itemsLabel, $$);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function drawBuckets(rs) {
|
|
616
|
+
const b = [0, 0, 0, 0];
|
|
617
|
+
for (const r of rs) for (let i = 0; i < 4; i++) b[i] += r.b[i] || 0;
|
|
618
|
+
bars('buckets', L.buckets.map((name, i) => [name, b[i]]), L.contextTitle, (v) => NF.format(v));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function drawLimits() {
|
|
622
|
+
const host = document.getElementById('limits');
|
|
623
|
+
host.textContent = '';
|
|
624
|
+
const b = bounds();
|
|
625
|
+
const hits = DATA.hits.filter(h => h[0] >= b.from && h[0] <= b.to && (!sel.machine || h[2] === sel.machine));
|
|
626
|
+
if (!hits.length) { const p = document.createElement('p'); p.className = 'empty'; p.textContent = L.limitsNone; host.append(p); return; }
|
|
627
|
+
const t = document.createElement('table');
|
|
628
|
+
for (const h of hits.slice(-10).reverse()) {
|
|
629
|
+
const tr = document.createElement('tr');
|
|
630
|
+
for (const v of [h[0] + ' ' + h[1], h[2], h[3]]) { const td = document.createElement('td'); td.textContent = v; tr.append(td); }
|
|
631
|
+
t.append(tr);
|
|
632
|
+
}
|
|
633
|
+
host.append(t);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const FROM = document.getElementById('from'), TO = document.getElementById('to');
|
|
637
|
+
const RANGE = document.getElementById('range');
|
|
638
|
+
// The date boxes are deliberately unbounded: a range without data shows the empty state,
|
|
639
|
+
// which says where the records start.
|
|
640
|
+
|
|
641
|
+
// With a preset active the boxes show the range it resolves to, but the preset stays selected.
|
|
642
|
+
function syncInputs() {
|
|
643
|
+
if (custom) {
|
|
644
|
+
// never clip a custom range: what the boxes show is what is applied
|
|
645
|
+
FROM.value = custom.from; TO.value = custom.to;
|
|
646
|
+
} else {
|
|
647
|
+
const b = bounds();
|
|
648
|
+
FROM.value = b.from < MIN ? MIN : b.from;
|
|
649
|
+
TO.value = b.to > MAX ? MAX : b.to;
|
|
650
|
+
}
|
|
651
|
+
RANGE.dataset.on = custom ? '1' : '0';
|
|
652
|
+
for (const o of document.querySelectorAll('.filters button[data-days]')) {
|
|
653
|
+
if (!custom && +o.dataset.days === days) o.setAttribute('aria-pressed', 'true');
|
|
654
|
+
else o.removeAttribute('aria-pressed');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
for (const b of document.querySelectorAll('.filters button[data-days]')) {
|
|
659
|
+
b.addEventListener('click', () => { days = +b.dataset.days; custom = null; syncInputs(); render(); });
|
|
660
|
+
}
|
|
661
|
+
for (const i of [FROM, TO]) {
|
|
662
|
+
i.addEventListener('change', () => {
|
|
663
|
+
if (!FROM.value || !TO.value) return;
|
|
664
|
+
// a reversed range is swapped rather than coming out empty
|
|
665
|
+
if (FROM.value > TO.value) { const t = FROM.value; FROM.value = TO.value; TO.value = t; }
|
|
666
|
+
custom = { from: FROM.value, to: TO.value };
|
|
667
|
+
syncInputs(); render();
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
document.getElementById('reset').addEventListener('click', () => { custom = null; days = 30; syncInputs(); render(); });
|
|
671
|
+
|
|
672
|
+
for (const [id, key] of [['fMachine', 'machine'], ['fProject', 'project'], ['fModel', 'model']]) {
|
|
673
|
+
const s = document.getElementById(id);
|
|
674
|
+
s.addEventListener('change', () => { sel[key] = s.value; s.classList.toggle('on', !!s.value); render(); });
|
|
675
|
+
}
|
|
676
|
+
document.getElementById('groupBy').addEventListener('change', (e) => { groupBy = e.target.value; render(); });
|
|
677
|
+
for (const b of document.querySelectorAll('[data-lang]')) {
|
|
678
|
+
b.addEventListener('click', () => {
|
|
679
|
+
lang = b.dataset.lang; L = STRINGS[lang];
|
|
680
|
+
try { localStorage.setItem('cc-cost-lang', lang); } catch (e) {}
|
|
681
|
+
applyText(); render();
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
addEventListener('resize', render);
|
|
686
|
+
applyText();
|
|
687
|
+
syncInputs();
|
|
688
|
+
render();
|
|
689
|
+
</script></body></html>`;
|
|
690
|
+
fs.writeFileSync(file, html);
|
|
691
|
+
return file;
|
|
692
|
+
}
|