token-trace-manager 0.7.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/ttm.mjs +343 -0
- package/commands/config.md +13 -0
- package/commands/dashboard.md +7 -0
- package/commands/link.md +12 -0
- package/commands/setup.md +19 -0
- package/commands/status.md +13 -0
- package/lib/adapters.mjs +428 -0
- package/lib/audit.mjs +238 -0
- package/lib/config.mjs +135 -0
- package/lib/daemon-client.mjs +139 -0
- package/lib/daemon.mjs +614 -0
- package/lib/db.mjs +444 -0
- package/lib/link.mjs +152 -0
- package/lib/mcp-server.mjs +275 -0
- package/lib/otel-export.mjs +122 -0
- package/lib/pricing.mjs +137 -0
- package/lib/report-data.mjs +171 -0
- package/lib/setup-snippets.mjs +201 -0
- package/lib/traces-data.mjs +155 -0
- package/lib/tui-config.mjs +429 -0
- package/package.json +56 -0
- package/templates/config.html +208 -0
- package/templates/dashboard.html +629 -0
- package/templates/traces.html +407 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ttm — tokens por tarefa</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Identidade: painel de instrumento de observabilidade — denso, preciso,
|
|
9
|
+
numerais mono tabulares. Paleta de referência validada (modo escuro)
|
|
10
|
+
contra o surface #1a1a19; categóricas em ordem fixa por entidade. */
|
|
11
|
+
:root {
|
|
12
|
+
--page: #0d0d0d;
|
|
13
|
+
--surface: #1a1a19;
|
|
14
|
+
--surface-2: #211f1e;
|
|
15
|
+
--ink: #f2f1ec;
|
|
16
|
+
--ink-2: #c3c2b7;
|
|
17
|
+
--muted: #898781;
|
|
18
|
+
--grid: #2c2c2a;
|
|
19
|
+
--baseline: #383835;
|
|
20
|
+
--border: rgba(255, 255, 255, 0.09);
|
|
21
|
+
--accent: #3987e5;
|
|
22
|
+
--accent-dim: #86b6ef;
|
|
23
|
+
--accent-wash: rgba(57, 135, 229, 0.13);
|
|
24
|
+
--warning: #fab219;
|
|
25
|
+
--good: #0ca30c;
|
|
26
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
27
|
+
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
28
|
+
/* categóricas (dark, validadas) — cor segue a ENTIDADE, nunca a posição */
|
|
29
|
+
--c-anthropic: #3987e5;
|
|
30
|
+
--c-openai: #199e70;
|
|
31
|
+
--c-gemini: #c98500;
|
|
32
|
+
--c-openrouter: #9085e9;
|
|
33
|
+
--c-outro: #898781;
|
|
34
|
+
}
|
|
35
|
+
* { box-sizing: border-box; }
|
|
36
|
+
html { color-scheme: dark; }
|
|
37
|
+
body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
background:
|
|
40
|
+
repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
|
|
41
|
+
radial-gradient(1200px 400px at 50% -140px, rgba(57,135,229,0.07), transparent 70%),
|
|
42
|
+
var(--page);
|
|
43
|
+
color: var(--ink);
|
|
44
|
+
font-family: var(--sans);
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
line-height: 1.45;
|
|
47
|
+
}
|
|
48
|
+
.wrap { max-width: 1360px; margin: 0 auto; padding: 0 28px 56px; }
|
|
49
|
+
|
|
50
|
+
/* ---- top bar ---- */
|
|
51
|
+
.topbar {
|
|
52
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
53
|
+
padding: 14px 0 12px;
|
|
54
|
+
border-bottom: 1px solid var(--border);
|
|
55
|
+
margin-bottom: 16px;
|
|
56
|
+
}
|
|
57
|
+
.brand { display: flex; align-items: baseline; gap: 10px; }
|
|
58
|
+
.brand .mark {
|
|
59
|
+
font-family: var(--mono); font-weight: 700; font-size: 15px; letter-spacing: 0.02em;
|
|
60
|
+
color: var(--ink);
|
|
61
|
+
}
|
|
62
|
+
.brand .mark::before { content: "▮▯▮ "; color: var(--accent); letter-spacing: -0.12em; margin-right: 2px; }
|
|
63
|
+
.brand .sub { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; }
|
|
64
|
+
.nav { display: flex; gap: 4px; }
|
|
65
|
+
.nav a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 5px 12px; border-radius: 6px; }
|
|
66
|
+
.nav a:hover { color: var(--ink); background: rgba(255,255,255,0.05); }
|
|
67
|
+
.nav a.active { color: var(--accent-dim); background: var(--accent-wash); font-weight: 600; }
|
|
68
|
+
.liveline { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 11px; font-family: var(--mono); }
|
|
69
|
+
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 6px rgba(12,163,12,0.7); }
|
|
70
|
+
|
|
71
|
+
/* ---- filtros ---- */
|
|
72
|
+
.filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
|
|
73
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; background: var(--surface); }
|
|
74
|
+
.seg a {
|
|
75
|
+
color: var(--ink-2); text-decoration: none; font-size: 12px; padding: 6px 13px;
|
|
76
|
+
border-right: 1px solid var(--border);
|
|
77
|
+
}
|
|
78
|
+
.seg a:last-child { border-right: 0; }
|
|
79
|
+
.seg a:hover { background: rgba(255,255,255,0.05); color: var(--ink); }
|
|
80
|
+
.seg a.active { background: var(--accent-wash); color: var(--accent-dim); font-weight: 600; }
|
|
81
|
+
.filters input {
|
|
82
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 7px;
|
|
83
|
+
color: var(--ink); padding: 6px 12px; font-size: 12px; width: 240px; font-family: var(--sans);
|
|
84
|
+
}
|
|
85
|
+
.filters input:focus { outline: none; border-color: var(--accent); }
|
|
86
|
+
.filters input::placeholder { color: var(--muted); }
|
|
87
|
+
.chip {
|
|
88
|
+
display: inline-flex; align-items: center; gap: 7px;
|
|
89
|
+
background: var(--accent-wash); border: 1px solid rgba(57,135,229,0.45);
|
|
90
|
+
border-radius: 7px; padding: 5px 11px; font-size: 12px; color: var(--accent-dim);
|
|
91
|
+
}
|
|
92
|
+
.chip a { color: var(--ink-2); text-decoration: none; }
|
|
93
|
+
.chip a:hover { color: var(--ink); }
|
|
94
|
+
.filters .note { color: var(--muted); font-size: 11px; }
|
|
95
|
+
|
|
96
|
+
/* ---- faixa de KPIs (banda única com divisórias, não cartões) ---- */
|
|
97
|
+
.kpis {
|
|
98
|
+
display: grid; grid-template-columns: repeat(5, 1fr);
|
|
99
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
100
|
+
margin-bottom: 20px; overflow: hidden;
|
|
101
|
+
}
|
|
102
|
+
.kpi { padding: 14px 18px 12px; border-right: 1px solid var(--grid); min-width: 0; }
|
|
103
|
+
.kpi:last-child { border-right: 0; }
|
|
104
|
+
.kpi .label { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.13em; }
|
|
105
|
+
.kpi .value {
|
|
106
|
+
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
|
107
|
+
font-size: 24px; font-weight: 600; margin-top: 3px; letter-spacing: -0.01em;
|
|
108
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
109
|
+
}
|
|
110
|
+
.kpi .sub { color: var(--muted); font-size: 11px; margin-top: 1px; font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
111
|
+
.kpi .value .unit { color: var(--muted); font-size: 12px; font-weight: 400; margin-left: 3px; }
|
|
112
|
+
@media (max-width: 980px) { .kpis { grid-template-columns: repeat(2, 1fr); } .kpi { border-bottom: 1px solid var(--grid); } }
|
|
113
|
+
|
|
114
|
+
/* ---- seções ---- */
|
|
115
|
+
section { margin-bottom: 20px; animation: rise 0.35s ease both; }
|
|
116
|
+
section:nth-of-type(2) { animation-delay: 0.04s; }
|
|
117
|
+
section:nth-of-type(3) { animation-delay: 0.08s; }
|
|
118
|
+
section:nth-of-type(4) { animation-delay: 0.12s; }
|
|
119
|
+
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
120
|
+
.card {
|
|
121
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
122
|
+
padding: 16px 18px; overflow-x: auto;
|
|
123
|
+
}
|
|
124
|
+
.card h2 {
|
|
125
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.13em;
|
|
126
|
+
color: var(--muted); margin: 0 0 12px; font-weight: 600;
|
|
127
|
+
display: flex; align-items: center; gap: 8px;
|
|
128
|
+
}
|
|
129
|
+
.card h2::before { content: ""; width: 8px; height: 8px; border: 2px solid var(--accent); border-radius: 2px; }
|
|
130
|
+
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
|
131
|
+
|
|
132
|
+
/* ---- tabela de tarefas (protagonista) ---- */
|
|
133
|
+
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
|
134
|
+
th, td { text-align: right; padding: 9px 12px; border-bottom: 1px solid var(--grid); white-space: nowrap; vertical-align: middle; }
|
|
135
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
136
|
+
th { color: var(--muted); font-weight: 500; font-size: 10px; text-transform: uppercase; letter-spacing: 0.11em; }
|
|
137
|
+
th:first-child, td:first-child { text-align: left; padding-left: 4px; }
|
|
138
|
+
td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
139
|
+
td.tok { font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; font-size: 13px; }
|
|
140
|
+
tr.task { transition: background 0.12s ease; }
|
|
141
|
+
tr.task:hover td { background: rgba(255, 255, 255, 0.028); }
|
|
142
|
+
.task-cell { display: flex; flex-direction: column; gap: 4px; min-width: 170px; }
|
|
143
|
+
.task-label { color: var(--ink); text-decoration: none; font-weight: 600; font-family: var(--mono); font-size: 12.5px; }
|
|
144
|
+
a.task-label:hover { color: var(--accent-dim); }
|
|
145
|
+
.share-track { height: 3px; background: var(--grid); border-radius: 2px; overflow: hidden; max-width: 150px; }
|
|
146
|
+
.share-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
|
147
|
+
.dim { color: var(--muted); }
|
|
148
|
+
.empty { color: var(--muted); padding: 14px 4px; }
|
|
149
|
+
.warn { color: var(--warning); font-size: 11px; margin-top: 10px; }
|
|
150
|
+
|
|
151
|
+
/* ação de linha: só aparece no hover (padrão trace apps) */
|
|
152
|
+
td.rowact { width: 96px; }
|
|
153
|
+
.copy-btn {
|
|
154
|
+
background: none; border: 1px solid transparent; border-radius: 6px;
|
|
155
|
+
color: var(--muted); cursor: pointer; font-size: 11px; font-family: var(--sans);
|
|
156
|
+
padding: 4px 10px; opacity: 0; transition: opacity 0.12s ease;
|
|
157
|
+
}
|
|
158
|
+
tr.task:hover .copy-btn, .copy-btn:focus-visible { opacity: 1; border-color: var(--border); color: var(--ink-2); }
|
|
159
|
+
.copy-btn:hover { border-color: var(--accent); color: var(--accent-dim); background: var(--accent-wash); }
|
|
160
|
+
.copy-btn.done { opacity: 1; border-color: var(--good); color: var(--good); }
|
|
161
|
+
@media (hover: none) { .copy-btn { opacity: 1; border-color: var(--border); } }
|
|
162
|
+
|
|
163
|
+
.spark { display: block; margin-left: auto; }
|
|
164
|
+
|
|
165
|
+
/* ---- gráfico por dia ---- */
|
|
166
|
+
.day-wrap { position: relative; padding-top: 4px; }
|
|
167
|
+
.day-max { position: absolute; top: 0; right: 0; color: var(--muted); font-size: 10px; font-family: var(--mono); }
|
|
168
|
+
.day-chart { display: flex; align-items: flex-end; gap: 3px; height: 120px; border-bottom: 1px solid var(--baseline); position: relative; }
|
|
169
|
+
.day-chart::before {
|
|
170
|
+
content: ""; position: absolute; inset: 0;
|
|
171
|
+
background: repeating-linear-gradient(0deg, transparent 0 29px, var(--grid) 29px 30px);
|
|
172
|
+
pointer-events: none;
|
|
173
|
+
}
|
|
174
|
+
.day-col { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; min-height: 2px; position: relative; max-width: 40px; transition: background 0.1s ease; }
|
|
175
|
+
.day-col:hover { background: var(--accent-dim); }
|
|
176
|
+
.day-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 5px; font-family: var(--mono); }
|
|
177
|
+
|
|
178
|
+
/* ---- listas com barras (magnitude, um matiz) + ponto de entidade ---- */
|
|
179
|
+
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
180
|
+
.bar-row .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--grid); }
|
|
181
|
+
.bar-row .key { width: 145px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); font-size: 12px; }
|
|
182
|
+
.bar-track { flex: 1; height: 12px; }
|
|
183
|
+
.bar-fill { background: var(--accent); height: 100%; border-radius: 0 3px 3px 0; min-width: 2px; }
|
|
184
|
+
.bar-row:hover .bar-fill { background: var(--accent-dim); }
|
|
185
|
+
.bar-val { width: 118px; text-align: right; flex-shrink: 0; color: var(--muted); font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; font-size: 11px; }
|
|
186
|
+
|
|
187
|
+
/* ---- trace list (chamadas) ---- */
|
|
188
|
+
#calls td { font-size: 12px; }
|
|
189
|
+
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
190
|
+
.flow { font-family: var(--mono); }
|
|
191
|
+
.flow .arr { color: var(--muted); margin: 0 4px; }
|
|
192
|
+
.pid { color: var(--muted); font-family: var(--mono); font-size: 10.5px; }
|
|
193
|
+
.provider { display: inline-flex; align-items: center; gap: 6px; }
|
|
194
|
+
.provider .dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
195
|
+
.st { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
|
|
196
|
+
.st.err { background: #e66767; }
|
|
197
|
+
|
|
198
|
+
/* ---- tooltip (recibo) ---- */
|
|
199
|
+
#tip {
|
|
200
|
+
position: fixed; display: none; z-index: 10; pointer-events: none;
|
|
201
|
+
background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
|
|
202
|
+
padding: 9px 12px; font-size: 11.5px; max-width: 360px;
|
|
203
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
|
|
204
|
+
font-family: var(--mono);
|
|
205
|
+
}
|
|
206
|
+
#tip .tv { font-weight: 700; font-size: 12.5px; color: var(--ink); }
|
|
207
|
+
#tip .tl { color: var(--muted); margin-top: 2px; }
|
|
208
|
+
</style>
|
|
209
|
+
</head>
|
|
210
|
+
<body>
|
|
211
|
+
<div class="wrap">
|
|
212
|
+
<div class="topbar">
|
|
213
|
+
<div class="brand">
|
|
214
|
+
<span class="mark">ttm</span>
|
|
215
|
+
<span class="sub">token trace manager</span>
|
|
216
|
+
</div>
|
|
217
|
+
<nav class="nav">
|
|
218
|
+
<a href="/dashboard" class="active">Visão geral</a>
|
|
219
|
+
<a href="/traces" id="nav-traces">Traces</a>
|
|
220
|
+
<a href="/config" id="nav-config">Config</a>
|
|
221
|
+
</nav>
|
|
222
|
+
<div class="liveline"><span class="live-dot" id="live-dot"></span><span id="meta"></span></div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div class="filters" id="filters">
|
|
226
|
+
<input type="search" id="task-search" placeholder="buscar tarefa (ex: PROJ-123)…" />
|
|
227
|
+
<span id="label-chip"></span>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div class="kpis">
|
|
231
|
+
<div class="kpi"><div class="label">Tokens lidos</div><div class="value" id="k-in">—</div><div class="sub" id="k-in-full"></div></div>
|
|
232
|
+
<div class="kpi"><div class="label">Tokens escritos</div><div class="value" id="k-out">—</div><div class="sub" id="k-out-full"></div></div>
|
|
233
|
+
<div class="kpi"><div class="label">Tarefas</div><div class="value" id="k-tasks">—</div><div class="sub">com tráfego no período</div></div>
|
|
234
|
+
<div class="kpi"><div class="label">Chamadas</div><div class="value" id="k-calls">—</div><div class="sub" id="k-calls-sub"></div></div>
|
|
235
|
+
<div class="kpi"><div class="label">Custo estimado</div><div class="value" id="k-cost">—</div><div class="sub" id="k-cost-sub"></div></div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<section>
|
|
239
|
+
<div class="card">
|
|
240
|
+
<h2>Tokens por tarefa — passe o mouse na linha e use “copiar” para o campo do ticket</h2>
|
|
241
|
+
<table id="tasks">
|
|
242
|
+
<thead>
|
|
243
|
+
<tr>
|
|
244
|
+
<th>Tarefa</th>
|
|
245
|
+
<th>Lidos</th>
|
|
246
|
+
<th>Escritos</th>
|
|
247
|
+
<th>Total</th>
|
|
248
|
+
<th>Chamadas</th>
|
|
249
|
+
<th>Custo</th>
|
|
250
|
+
<th>Última atividade</th>
|
|
251
|
+
<th>Evolução</th>
|
|
252
|
+
<th></th>
|
|
253
|
+
</tr>
|
|
254
|
+
</thead>
|
|
255
|
+
<tbody></tbody>
|
|
256
|
+
</table>
|
|
257
|
+
<div class="warn" id="cost-warn" style="display:none">custo parcial: há chamadas com modelos sem preço na tabela — os tokens estão completos mesmo assim</div>
|
|
258
|
+
</div>
|
|
259
|
+
</section>
|
|
260
|
+
|
|
261
|
+
<section>
|
|
262
|
+
<div class="card">
|
|
263
|
+
<h2>Tokens por dia</h2>
|
|
264
|
+
<div class="day-wrap">
|
|
265
|
+
<div class="day-max" id="day-max"></div>
|
|
266
|
+
<div class="day-chart" id="by-day"></div>
|
|
267
|
+
<div class="day-axis" id="day-axis"></div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</section>
|
|
271
|
+
|
|
272
|
+
<section>
|
|
273
|
+
<div class="grid2">
|
|
274
|
+
<div class="card"><h2>Por provedor</h2><div id="by-target"></div></div>
|
|
275
|
+
<div class="card"><h2>Por modelo</h2><div id="by-model"></div></div>
|
|
276
|
+
<div class="card"><h2>Por cliente (TUI)</h2><div id="by-client"></div></div>
|
|
277
|
+
</div>
|
|
278
|
+
</section>
|
|
279
|
+
|
|
280
|
+
<section>
|
|
281
|
+
<div class="card">
|
|
282
|
+
<h2>Chamadas individuais (auditoria) — hover mostra a conta aberta do custo</h2>
|
|
283
|
+
<table id="calls">
|
|
284
|
+
<thead>
|
|
285
|
+
<tr>
|
|
286
|
+
<th></th>
|
|
287
|
+
<th>Quando</th>
|
|
288
|
+
<th>Tarefa</th>
|
|
289
|
+
<th>Provedor</th>
|
|
290
|
+
<th>Modelo</th>
|
|
291
|
+
<th>Lidos → escritos</th>
|
|
292
|
+
<th>Custo</th>
|
|
293
|
+
<th>Duração</th>
|
|
294
|
+
<th>ID no provedor</th>
|
|
295
|
+
</tr>
|
|
296
|
+
</thead>
|
|
297
|
+
<tbody></tbody>
|
|
298
|
+
</table>
|
|
299
|
+
</div>
|
|
300
|
+
</section>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<div id="tip"></div>
|
|
304
|
+
|
|
305
|
+
<script id="report-data" type="application/json">"__REPORT_DATA__"</script>
|
|
306
|
+
<script>
|
|
307
|
+
const report = JSON.parse(document.getElementById('report-data').textContent)
|
|
308
|
+
|
|
309
|
+
// Labels/models/clients vêm de headers e URLs controláveis pelo cliente:
|
|
310
|
+
// escapar SEMPRE ao usar innerHTML; tooltips usam só textContent.
|
|
311
|
+
function esc(s) {
|
|
312
|
+
return String(s).replace(/[&<>"']/g, c =>
|
|
313
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
const nf = new Intl.NumberFormat('pt-BR')
|
|
317
|
+
function compact(n) {
|
|
318
|
+
n = n || 0
|
|
319
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2).replace('.', ',') + 'B'
|
|
320
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2).replace('.', ',') + 'M'
|
|
321
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1).replace('.', ',') + 'k'
|
|
322
|
+
return nf.format(n)
|
|
323
|
+
}
|
|
324
|
+
const usd = n => '$' + (n || 0).toFixed(2)
|
|
325
|
+
const dtf = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
326
|
+
const PROVIDER_COLORS = {
|
|
327
|
+
anthropic: 'var(--c-anthropic)',
|
|
328
|
+
openai: 'var(--c-openai)',
|
|
329
|
+
gemini: 'var(--c-gemini)',
|
|
330
|
+
openrouter: 'var(--c-openrouter)',
|
|
331
|
+
}
|
|
332
|
+
const providerColor = t => PROVIDER_COLORS[t] || 'var(--c-outro)'
|
|
333
|
+
|
|
334
|
+
// ------- topo, filtros e KPIs -------
|
|
335
|
+
const params = new URLSearchParams(location.search)
|
|
336
|
+
const activeSince = report.since || '30d'
|
|
337
|
+
document.getElementById('meta').textContent = new Date(report.generatedAt).toLocaleString('pt-BR')
|
|
338
|
+
|
|
339
|
+
const isFile = location.protocol === 'file:'
|
|
340
|
+
if (isFile) {
|
|
341
|
+
document.getElementById('live-dot').style.background = 'var(--muted)'
|
|
342
|
+
document.querySelector('.nav').style.display = 'none' // páginas ao vivo não existem no export
|
|
343
|
+
}
|
|
344
|
+
const presets = [['24h', 'Hoje'], ['7d', '7 dias'], ['30d', '30 dias'], ['all', 'Tudo']]
|
|
345
|
+
const seg = document.createElement('div')
|
|
346
|
+
seg.className = 'seg'
|
|
347
|
+
for (const [val, text] of presets) {
|
|
348
|
+
const a = document.createElement('a')
|
|
349
|
+
const p = new URLSearchParams(params)
|
|
350
|
+
p.set('since', val)
|
|
351
|
+
a.href = isFile ? '#' : '?' + p.toString()
|
|
352
|
+
a.textContent = text
|
|
353
|
+
if (val === activeSince) a.className = 'active'
|
|
354
|
+
if (isFile) a.addEventListener('click', e => e.preventDefault())
|
|
355
|
+
seg.appendChild(a)
|
|
356
|
+
}
|
|
357
|
+
const filtersEl = document.getElementById('filters')
|
|
358
|
+
filtersEl.prepend(seg)
|
|
359
|
+
if (isFile) {
|
|
360
|
+
const note = document.createElement('span')
|
|
361
|
+
note.className = 'note'
|
|
362
|
+
note.textContent = 'exportação estática — filtros de período só no dashboard ao vivo'
|
|
363
|
+
filtersEl.appendChild(note)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (report.label) {
|
|
367
|
+
const chip = document.getElementById('label-chip')
|
|
368
|
+
chip.innerHTML = `<span class="chip">tarefa: <strong>${esc(report.label)}</strong> <a href="${isFile ? '#' : '?since=' + encodeURIComponent(activeSince)}" title="limpar filtro">✕</a></span>`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
document.getElementById('k-in').textContent = compact(report.tokensIn)
|
|
372
|
+
document.getElementById('k-in-full').textContent = nf.format(report.tokensIn || 0) + ' · entrada + cache'
|
|
373
|
+
document.getElementById('k-out').textContent = compact(report.tokensOut)
|
|
374
|
+
document.getElementById('k-out-full').textContent = nf.format(report.tokensOut || 0) + ' · gerados'
|
|
375
|
+
document.getElementById('k-tasks').textContent = report.taskCount || 0
|
|
376
|
+
document.getElementById('k-calls').textContent = nf.format(report.eventCount || 0)
|
|
377
|
+
document.getElementById('k-calls-sub').textContent = 'janela: ' + ({ '24h': 'hoje', '7d': '7 dias', '30d': '30 dias', all: 'tudo' }[activeSince] || activeSince)
|
|
378
|
+
document.getElementById('k-cost').textContent = usd(report.totalCost) + (report.costKnown ? '' : '*')
|
|
379
|
+
document.getElementById('k-cost-sub').textContent = report.costKnown ? 'tabela de preços local' : '* parcial — modelo(s) sem preço'
|
|
380
|
+
if (!report.costKnown) document.getElementById('cost-warn').style.display = 'block'
|
|
381
|
+
|
|
382
|
+
// ------- tooltip único (textContent — nunca innerHTML com dados) -------
|
|
383
|
+
const tip = document.getElementById('tip')
|
|
384
|
+
function showTip(ev, lines) {
|
|
385
|
+
tip.replaceChildren(
|
|
386
|
+
...lines.map(([cls, text]) => {
|
|
387
|
+
const d = document.createElement('div')
|
|
388
|
+
d.className = cls
|
|
389
|
+
d.textContent = text
|
|
390
|
+
return d
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
tip.style.display = 'block'
|
|
394
|
+
const pad = 14
|
|
395
|
+
const x = Math.min(ev.clientX + pad, window.innerWidth - tip.offsetWidth - pad)
|
|
396
|
+
const y = Math.min(ev.clientY + pad, window.innerHeight - tip.offsetHeight - pad)
|
|
397
|
+
tip.style.left = x + 'px'
|
|
398
|
+
tip.style.top = y + 'px'
|
|
399
|
+
}
|
|
400
|
+
const hideTip = () => (tip.style.display = 'none')
|
|
401
|
+
|
|
402
|
+
// ------- tabela de tarefas -------
|
|
403
|
+
function sparkline(byDay) {
|
|
404
|
+
if (!byDay || byDay.length < 2) return '<span class="dim">—</span>'
|
|
405
|
+
const MAXB = 14
|
|
406
|
+
let buckets = byDay
|
|
407
|
+
if (byDay.length > MAXB) {
|
|
408
|
+
const size = Math.ceil(byDay.length / MAXB)
|
|
409
|
+
buckets = []
|
|
410
|
+
for (let i = 0; i < byDay.length; i += size) {
|
|
411
|
+
const slice = byDay.slice(i, i + size)
|
|
412
|
+
buckets.push({ day: slice[0].day, tokens: slice.reduce((s, d) => s + d.tokens, 0) })
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const w = 4, gap = 2, h = 18
|
|
416
|
+
const max = Math.max(...buckets.map(b => b.tokens), 1)
|
|
417
|
+
const bars = buckets
|
|
418
|
+
.map((b, i) => {
|
|
419
|
+
const bh = Math.max(1.5, (b.tokens / max) * h)
|
|
420
|
+
return `<rect x="${i * (w + gap)}" y="${(h - bh).toFixed(1)}" width="${w}" height="${bh.toFixed(1)}" rx="1" fill="var(--accent)"/>`
|
|
421
|
+
})
|
|
422
|
+
.join('')
|
|
423
|
+
return `<svg class="spark" width="${buckets.length * (w + gap)}" height="${h}" aria-hidden="true">${bars}</svg>`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function copyText(text) {
|
|
427
|
+
try {
|
|
428
|
+
await navigator.clipboard.writeText(text)
|
|
429
|
+
return true
|
|
430
|
+
} catch {
|
|
431
|
+
const ta = document.createElement('textarea')
|
|
432
|
+
ta.value = text
|
|
433
|
+
document.body.appendChild(ta)
|
|
434
|
+
ta.select()
|
|
435
|
+
const ok = document.execCommand('copy')
|
|
436
|
+
ta.remove()
|
|
437
|
+
return ok
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const tbody = document.querySelector('#tasks tbody')
|
|
442
|
+
const tasks = report.tasks || []
|
|
443
|
+
const maxTaskTokens = Math.max(...tasks.map(t => t.tokens), 1)
|
|
444
|
+
if (!tasks.length) {
|
|
445
|
+
tbody.innerHTML = '<tr><td colspan="9" class="empty">Nenhuma chamada no período. Faça o teste do README: ANTHROPIC_BASE_URL=…/anthropic/TESTE-1 claude -p "ok"</td></tr>'
|
|
446
|
+
} else {
|
|
447
|
+
tbody.innerHTML = tasks
|
|
448
|
+
.map((t, i) => {
|
|
449
|
+
const labelCell =
|
|
450
|
+
t.key === 'Sem label' || isFile
|
|
451
|
+
? `<span class="task-label dim">${esc(t.key)}</span>`
|
|
452
|
+
: `<a class="task-label" href="?since=${encodeURIComponent(activeSince)}&label=${encodeURIComponent(t.key)}" title="filtrar o dashboard por esta tarefa">${esc(t.key)}</a>`
|
|
453
|
+
const copia = `${t.tokensIn} lidos / ${t.tokensOut} escritos`
|
|
454
|
+
return `<tr class="task" data-i="${i}">
|
|
455
|
+
<td><div class="task-cell">${labelCell}<div class="share-track"><div class="share-fill" style="width:${Math.max(2, (t.tokens / maxTaskTokens) * 100)}%"></div></div></div></td>
|
|
456
|
+
<td class="tok" title="${nf.format(t.tokensIn)} lidos (entrada + cache)">${compact(t.tokensIn)}</td>
|
|
457
|
+
<td class="tok" title="${nf.format(t.tokensOut)} escritos">${compact(t.tokensOut)}</td>
|
|
458
|
+
<td class="num dim" title="${nf.format(t.tokens)} tokens no total">${compact(t.tokens)}</td>
|
|
459
|
+
<td class="num dim">${nf.format(t.events)}</td>
|
|
460
|
+
<td class="num dim">${usd(t.cost)}</td>
|
|
461
|
+
<td class="dim mono" style="font-size:11px">${t.lastTs ? dtf.format(new Date(t.lastTs)) : '—'}</td>
|
|
462
|
+
<td>${sparkline(t.byDay)}</td>
|
|
463
|
+
<td class="rowact"><button class="copy-btn" data-copia="${esc(copia)}" title="copiar '${esc(copia)}' — cole no campo 'tokens gastos' do ticket">⧉ copiar</button></td>
|
|
464
|
+
</tr>`
|
|
465
|
+
})
|
|
466
|
+
.join('')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
tbody.addEventListener('click', async e => {
|
|
470
|
+
const btn = e.target.closest('.copy-btn')
|
|
471
|
+
if (!btn) return
|
|
472
|
+
// Copia "X lidos / Y escritos" — a métrica do campo "tokens gastos"
|
|
473
|
+
if (await copyText(btn.dataset.copia)) {
|
|
474
|
+
btn.textContent = 'copiado ✓'
|
|
475
|
+
btn.classList.add('done')
|
|
476
|
+
setTimeout(() => {
|
|
477
|
+
btn.textContent = '⧉ copiar'
|
|
478
|
+
btn.classList.remove('done')
|
|
479
|
+
}, 1600)
|
|
480
|
+
}
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
tbody.addEventListener('pointermove', e => {
|
|
484
|
+
const tr = e.target.closest('tr.task')
|
|
485
|
+
if (!tr) return hideTip()
|
|
486
|
+
const t = tasks[Number(tr.dataset.i)]
|
|
487
|
+
if (!t) return hideTip()
|
|
488
|
+
showTip(e, [
|
|
489
|
+
['tv', `${nf.format(t.tokensIn)} lidos / ${nf.format(t.tokensOut)} escritos`],
|
|
490
|
+
['tl', t.key],
|
|
491
|
+
['tl', `lidos = entrada ${nf.format(t.input)} + cache lido ${nf.format(t.cacheRead)} + cache escrito ${nf.format(t.cacheCreate)}`],
|
|
492
|
+
['tl', `${nf.format(t.events)} chamadas · ${usd(t.cost)}${t.reasoning ? ' · reasoning ' + nf.format(t.reasoning) : ''}`],
|
|
493
|
+
])
|
|
494
|
+
})
|
|
495
|
+
tbody.addEventListener('pointerleave', hideTip)
|
|
496
|
+
|
|
497
|
+
// ------- busca (client-side, filtra as linhas da tabela) -------
|
|
498
|
+
document.getElementById('task-search').addEventListener('input', e => {
|
|
499
|
+
const q = e.target.value.trim().toLowerCase()
|
|
500
|
+
for (const tr of tbody.querySelectorAll('tr.task')) {
|
|
501
|
+
const t = tasks[Number(tr.dataset.i)]
|
|
502
|
+
tr.style.display = !q || t.key.toLowerCase().includes(q) ? '' : 'none'
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ------- chamadas individuais (trace list de auditoria) -------
|
|
507
|
+
const calls = report.recentCalls || []
|
|
508
|
+
const callsBody = document.querySelector('#calls tbody')
|
|
509
|
+
if (!calls.length) {
|
|
510
|
+
callsBody.innerHTML = '<tr><td colspan="9" class="empty">Sem chamadas no período.</td></tr>'
|
|
511
|
+
} else {
|
|
512
|
+
callsBody.innerHTML = calls
|
|
513
|
+
.map((c, i) => {
|
|
514
|
+
const lidos = c.input + c.cacheCreate + c.cacheRead
|
|
515
|
+
return `<tr class="task" data-i="${i}">
|
|
516
|
+
<td style="width:18px"><span class="st${c.status && c.status >= 400 ? ' err' : ''}"></span></td>
|
|
517
|
+
<td class="dim mono" style="font-size:11px">${dtf.format(new Date(c.ts))}</td>
|
|
518
|
+
<td class="mono">${esc(c.label ?? '—')}</td>
|
|
519
|
+
<td><span class="provider"><span class="dot" style="background:${providerColor(c.target)}"></span><span class="dim">${esc(c.target)}</span></span></td>
|
|
520
|
+
<td class="dim mono" style="font-size:11px">${esc(c.model ?? '?')}</td>
|
|
521
|
+
<td class="flow"><span title="${nf.format(lidos)} lidos">${compact(lidos)}</span><span class="arr">→</span><span title="${nf.format(c.output)} escritos">${compact(c.output)}</span></td>
|
|
522
|
+
<td class="num">${c.cost === null || c.cost === undefined ? '<span class="dim">sem preço</span>' : usd(c.cost)}</td>
|
|
523
|
+
<td class="num dim">${c.durationMs ? (c.durationMs / 1000).toFixed(1) + 's' : '—'}</td>
|
|
524
|
+
<td class="pid">${esc(c.providerId ?? '—')}</td>
|
|
525
|
+
</tr>`
|
|
526
|
+
})
|
|
527
|
+
.join('')
|
|
528
|
+
|
|
529
|
+
// Tooltip = o recibo do cálculo: cada parcela tokens × taxa gravada
|
|
530
|
+
callsBody.addEventListener('pointermove', e => {
|
|
531
|
+
const tr = e.target.closest('tr.task')
|
|
532
|
+
if (!tr) return hideTip()
|
|
533
|
+
const c = calls[Number(tr.dataset.i)]
|
|
534
|
+
if (!c) return hideTip()
|
|
535
|
+
const lines = [
|
|
536
|
+
['tv', c.cost === null || c.cost === undefined ? 'custo indisponível (modelo sem preço)' : usd(c.cost)],
|
|
537
|
+
['tl', (c.model ?? '?') + ' · ' + (c.client ?? '')],
|
|
538
|
+
]
|
|
539
|
+
if (c.rates) {
|
|
540
|
+
const r = c.rates
|
|
541
|
+
const c1h = Math.min(c.cacheCreate1h, c.cacheCreate)
|
|
542
|
+
const c5m = c.cacheCreate - c1h
|
|
543
|
+
const parts = [
|
|
544
|
+
[c.input, r.input, 'entrada'],
|
|
545
|
+
[c.cacheRead, r.cacheRead, 'cache lido'],
|
|
546
|
+
[c5m, r.cacheWrite5m, 'cache escrito 5m'],
|
|
547
|
+
[c1h, r.cacheWrite1h, 'cache escrito 1h'],
|
|
548
|
+
[c.output, r.output, 'saída'],
|
|
549
|
+
]
|
|
550
|
+
for (const [tok, rate, name] of parts) {
|
|
551
|
+
if (tok > 0) lines.push(['tl', `${nf.format(tok)} × $${rate}/M = $${((tok / 1e6) * rate).toFixed(6)} (${name})`])
|
|
552
|
+
}
|
|
553
|
+
lines.push(['tl', `tabela de preços de ${r.pricingDate}`])
|
|
554
|
+
} else {
|
|
555
|
+
lines.push(['tl', 'evento anterior ao recibo de taxas — sem decomposição'])
|
|
556
|
+
}
|
|
557
|
+
showTip(e, lines)
|
|
558
|
+
})
|
|
559
|
+
callsBody.addEventListener('pointerleave', hideTip)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ------- listas com barras (magnitude, um matiz; ponto = entidade) -------
|
|
563
|
+
function renderBars(containerId, items, dotFor) {
|
|
564
|
+
const el = document.getElementById(containerId)
|
|
565
|
+
if (!items || !items.length) {
|
|
566
|
+
el.innerHTML = '<div class="empty">Sem dados no período.</div>'
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
const top = items.slice(0, 10)
|
|
570
|
+
const max = Math.max(...top.map(i => i.tokens), 1e-9)
|
|
571
|
+
el.innerHTML = top
|
|
572
|
+
.map(
|
|
573
|
+
(i, idx) => `<div class="bar-row" data-i="${idx}">
|
|
574
|
+
${dotFor ? `<span class="dot" style="background:${dotFor(i.key)}"></span>` : ''}
|
|
575
|
+
<div class="key" title="${esc(i.key)}">${esc(i.key)}</div>
|
|
576
|
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.max(1.5, (i.tokens / max) * 100)}%"></div></div>
|
|
577
|
+
<div class="bar-val">${compact(i.tokens)} tok · ${usd(i.cost)}</div>
|
|
578
|
+
</div>`,
|
|
579
|
+
)
|
|
580
|
+
.join('')
|
|
581
|
+
el.addEventListener('pointermove', e => {
|
|
582
|
+
const row = e.target.closest('.bar-row')
|
|
583
|
+
if (!row) return hideTip()
|
|
584
|
+
const i = top[Number(row.dataset.i)]
|
|
585
|
+
showTip(e, [
|
|
586
|
+
['tv', nf.format(i.tokens) + ' tokens'],
|
|
587
|
+
['tl', i.key],
|
|
588
|
+
['tl', `${nf.format(i.events)} chamadas · ${usd(i.cost)}`],
|
|
589
|
+
])
|
|
590
|
+
})
|
|
591
|
+
el.addEventListener('pointerleave', hideTip)
|
|
592
|
+
}
|
|
593
|
+
renderBars('by-target', report.byTarget, providerColor)
|
|
594
|
+
renderBars('by-model', report.byModel, null)
|
|
595
|
+
renderBars('by-client', report.byClient, null)
|
|
596
|
+
|
|
597
|
+
// ------- colunas por dia -------
|
|
598
|
+
const dayEl = document.getElementById('by-day')
|
|
599
|
+
const days = report.byDay || []
|
|
600
|
+
if (!days.length) {
|
|
601
|
+
dayEl.outerHTML = '<div class="empty">Sem dados no período.</div>'
|
|
602
|
+
document.getElementById('day-axis').remove()
|
|
603
|
+
document.getElementById('day-max').remove()
|
|
604
|
+
} else {
|
|
605
|
+
const max = Math.max(...days.map(d => d.tokens), 1)
|
|
606
|
+
document.getElementById('day-max').textContent = 'máx ' + compact(max)
|
|
607
|
+
dayEl.innerHTML = days
|
|
608
|
+
.map(
|
|
609
|
+
(d, i) => `<div class="day-col" data-i="${i}" style="height:${Math.max(2, (d.tokens / max) * 100)}%"></div>`,
|
|
610
|
+
)
|
|
611
|
+
.join('')
|
|
612
|
+
dayEl.addEventListener('pointermove', e => {
|
|
613
|
+
const col = e.target.closest('.day-col')
|
|
614
|
+
if (!col) return hideTip()
|
|
615
|
+
const d = days[Number(col.dataset.i)]
|
|
616
|
+
showTip(e, [
|
|
617
|
+
['tv', nf.format(d.tokens) + ' tokens'],
|
|
618
|
+
['tl', d.day.split('-').reverse().join('/')],
|
|
619
|
+
['tl', `${nf.format(d.events)} chamadas · ${usd(d.cost)}`],
|
|
620
|
+
])
|
|
621
|
+
})
|
|
622
|
+
dayEl.addEventListener('pointerleave', hideTip)
|
|
623
|
+
const axis = document.getElementById('day-axis')
|
|
624
|
+
const fmtDay = s => s.slice(8, 10) + '/' + s.slice(5, 7)
|
|
625
|
+
axis.innerHTML = `<span>${fmtDay(days[0].day)}</span><span>${fmtDay(days[days.length - 1].day)}</span>`
|
|
626
|
+
}
|
|
627
|
+
</script>
|
|
628
|
+
</body>
|
|
629
|
+
</html>
|