@diogonzafe/tokenwatch 0.6.0 → 0.8.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 +36 -9
- package/dist/adapters.cjs +17 -6
- package/dist/adapters.cjs.map +1 -1
- package/dist/adapters.d.cts +2 -1
- package/dist/adapters.d.ts +2 -1
- package/dist/adapters.js +17 -6
- package/dist/adapters.js.map +1 -1
- package/dist/cli.js +1978 -550
- package/dist/cli.js.map +1 -1
- package/dist/exporters.d.cts +1 -1
- package/dist/exporters.d.ts +1 -1
- package/dist/{index-D9xq0RNg.d.cts → index-CFBI-1ab.d.cts} +12 -0
- package/dist/{index-D9xq0RNg.d.ts → index-CFBI-1ab.d.ts} +12 -0
- package/dist/index.cjs +128 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +128 -17
- package/dist/index.js.map +1 -1
- package/dist/langchain.d.cts +1 -1
- package/dist/langchain.d.ts +1 -1
- package/package.json +2 -2
- package/prices.json +102 -9
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,297 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/adapters/postgres.ts
|
|
13
|
+
var postgres_exports = {};
|
|
14
|
+
__export(postgres_exports, {
|
|
15
|
+
PostgresStorage: () => PostgresStorage
|
|
16
|
+
});
|
|
17
|
+
function rowToEntry(r) {
|
|
18
|
+
const reasoningTokens = r["reasoning_tokens"] ?? 0;
|
|
19
|
+
const cachedTokens = r["cached_tokens"] ?? 0;
|
|
20
|
+
const cacheCreationTokens = r["cache_creation_tokens"] ?? 0;
|
|
21
|
+
return {
|
|
22
|
+
model: r["model"],
|
|
23
|
+
inputTokens: r["input_tokens"],
|
|
24
|
+
outputTokens: r["output_tokens"],
|
|
25
|
+
...reasoningTokens > 0 && { reasoningTokens },
|
|
26
|
+
...cachedTokens > 0 && { cachedTokens },
|
|
27
|
+
...cacheCreationTokens > 0 && { cacheCreationTokens },
|
|
28
|
+
costUSD: Number(r["cost_usd"]),
|
|
29
|
+
...r["session_id"] != null && { sessionId: r["session_id"] },
|
|
30
|
+
...r["user_id"] != null && { userId: r["user_id"] },
|
|
31
|
+
...r["feature"] != null && { feature: r["feature"] },
|
|
32
|
+
...r["app_id"] != null && { appId: r["app_id"] },
|
|
33
|
+
timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
var PostgresStorage;
|
|
37
|
+
var init_postgres = __esm({
|
|
38
|
+
"src/adapters/postgres.ts"() {
|
|
39
|
+
"use strict";
|
|
40
|
+
PostgresStorage = class {
|
|
41
|
+
constructor(client) {
|
|
42
|
+
this.client = client;
|
|
43
|
+
}
|
|
44
|
+
client;
|
|
45
|
+
/** Creates the `tokenwatch_usage` table if it does not already exist.
|
|
46
|
+
* Also adds new columns for databases created before v0.2.0 / v0.3.0. */
|
|
47
|
+
async migrate() {
|
|
48
|
+
await this.client.query(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS tokenwatch_usage (
|
|
50
|
+
id BIGSERIAL PRIMARY KEY,
|
|
51
|
+
model TEXT NOT NULL,
|
|
52
|
+
input_tokens INTEGER NOT NULL,
|
|
53
|
+
output_tokens INTEGER NOT NULL,
|
|
54
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
cost_usd NUMERIC NOT NULL,
|
|
58
|
+
session_id TEXT,
|
|
59
|
+
user_id TEXT,
|
|
60
|
+
feature TEXT,
|
|
61
|
+
app_id TEXT,
|
|
62
|
+
timestamp TIMESTAMPTZ NOT NULL
|
|
63
|
+
)
|
|
64
|
+
`);
|
|
65
|
+
for (const col of [
|
|
66
|
+
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS reasoning_tokens INTEGER NOT NULL DEFAULT 0",
|
|
67
|
+
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS feature TEXT",
|
|
68
|
+
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cached_tokens INTEGER NOT NULL DEFAULT 0",
|
|
69
|
+
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cache_creation_tokens INTEGER NOT NULL DEFAULT 0",
|
|
70
|
+
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS app_id TEXT"
|
|
71
|
+
]) {
|
|
72
|
+
await this.client.query(col).catch(() => {
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
record(entry) {
|
|
77
|
+
this.client.query(
|
|
78
|
+
`INSERT INTO tokenwatch_usage
|
|
79
|
+
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
80
|
+
cost_usd, session_id, user_id, feature, app_id, timestamp)
|
|
81
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
82
|
+
[
|
|
83
|
+
entry.model,
|
|
84
|
+
entry.inputTokens,
|
|
85
|
+
entry.outputTokens,
|
|
86
|
+
entry.reasoningTokens ?? 0,
|
|
87
|
+
entry.cachedTokens ?? 0,
|
|
88
|
+
entry.cacheCreationTokens ?? 0,
|
|
89
|
+
entry.costUSD,
|
|
90
|
+
entry.sessionId ?? null,
|
|
91
|
+
entry.userId ?? null,
|
|
92
|
+
entry.feature ?? null,
|
|
93
|
+
entry.appId ?? null,
|
|
94
|
+
entry.timestamp
|
|
95
|
+
]
|
|
96
|
+
).catch((err) => {
|
|
97
|
+
console.warn("[tokenwatch] PostgresStorage.record failed:", err);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async getAll() {
|
|
101
|
+
const result = await this.client.query(
|
|
102
|
+
"SELECT * FROM tokenwatch_usage ORDER BY timestamp ASC"
|
|
103
|
+
);
|
|
104
|
+
return result.rows.map(rowToEntry);
|
|
105
|
+
}
|
|
106
|
+
async clearAll() {
|
|
107
|
+
await this.client.query("DELETE FROM tokenwatch_usage");
|
|
108
|
+
}
|
|
109
|
+
async clearSession(sessionId) {
|
|
110
|
+
await this.client.query(
|
|
111
|
+
"DELETE FROM tokenwatch_usage WHERE session_id = $1",
|
|
112
|
+
[sessionId]
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// src/adapters/mysql.ts
|
|
120
|
+
var mysql_exports = {};
|
|
121
|
+
__export(mysql_exports, {
|
|
122
|
+
MySQLStorage: () => MySQLStorage
|
|
123
|
+
});
|
|
124
|
+
function rowToEntry2(r) {
|
|
125
|
+
const reasoningTokens = r["reasoning_tokens"] ?? 0;
|
|
126
|
+
const cachedTokens = r["cached_tokens"] ?? 0;
|
|
127
|
+
const cacheCreationTokens = r["cache_creation_tokens"] ?? 0;
|
|
128
|
+
return {
|
|
129
|
+
model: r["model"],
|
|
130
|
+
inputTokens: r["input_tokens"],
|
|
131
|
+
outputTokens: r["output_tokens"],
|
|
132
|
+
...reasoningTokens > 0 && { reasoningTokens },
|
|
133
|
+
...cachedTokens > 0 && { cachedTokens },
|
|
134
|
+
...cacheCreationTokens > 0 && { cacheCreationTokens },
|
|
135
|
+
costUSD: Number(r["cost_usd"]),
|
|
136
|
+
...r["session_id"] != null && { sessionId: r["session_id"] },
|
|
137
|
+
...r["user_id"] != null && { userId: r["user_id"] },
|
|
138
|
+
...r["feature"] != null && { feature: r["feature"] },
|
|
139
|
+
...r["app_id"] != null && { appId: r["app_id"] },
|
|
140
|
+
timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
var MySQLStorage;
|
|
144
|
+
var init_mysql = __esm({
|
|
145
|
+
"src/adapters/mysql.ts"() {
|
|
146
|
+
"use strict";
|
|
147
|
+
MySQLStorage = class {
|
|
148
|
+
constructor(client) {
|
|
149
|
+
this.client = client;
|
|
150
|
+
}
|
|
151
|
+
client;
|
|
152
|
+
/** Creates the `tokenwatch_usage` table if it does not already exist.
|
|
153
|
+
* Also adds new columns for databases created before v0.2.0 / v0.3.0. */
|
|
154
|
+
async migrate() {
|
|
155
|
+
await this.client.execute(`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS tokenwatch_usage (
|
|
157
|
+
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
158
|
+
model VARCHAR(255) NOT NULL,
|
|
159
|
+
input_tokens INT NOT NULL,
|
|
160
|
+
output_tokens INT NOT NULL,
|
|
161
|
+
reasoning_tokens INT NOT NULL DEFAULT 0,
|
|
162
|
+
cached_tokens INT NOT NULL DEFAULT 0,
|
|
163
|
+
cache_creation_tokens INT NOT NULL DEFAULT 0,
|
|
164
|
+
cost_usd DECIMAL(18,8) NOT NULL,
|
|
165
|
+
session_id VARCHAR(255),
|
|
166
|
+
user_id VARCHAR(255),
|
|
167
|
+
feature VARCHAR(255),
|
|
168
|
+
app_id VARCHAR(255),
|
|
169
|
+
timestamp DATETIME(3) NOT NULL
|
|
170
|
+
)
|
|
171
|
+
`);
|
|
172
|
+
await this.client.execute(`
|
|
173
|
+
ALTER TABLE tokenwatch_usage
|
|
174
|
+
ADD COLUMN IF NOT EXISTS reasoning_tokens INT NOT NULL DEFAULT 0,
|
|
175
|
+
ADD COLUMN IF NOT EXISTS feature VARCHAR(255),
|
|
176
|
+
ADD COLUMN IF NOT EXISTS cached_tokens INT NOT NULL DEFAULT 0,
|
|
177
|
+
ADD COLUMN IF NOT EXISTS cache_creation_tokens INT NOT NULL DEFAULT 0,
|
|
178
|
+
ADD COLUMN IF NOT EXISTS app_id VARCHAR(255)
|
|
179
|
+
`).catch(() => {
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
record(entry) {
|
|
183
|
+
this.client.execute(
|
|
184
|
+
`INSERT INTO tokenwatch_usage
|
|
185
|
+
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
186
|
+
cost_usd, session_id, user_id, feature, app_id, timestamp)
|
|
187
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
188
|
+
[
|
|
189
|
+
entry.model,
|
|
190
|
+
entry.inputTokens,
|
|
191
|
+
entry.outputTokens,
|
|
192
|
+
entry.reasoningTokens ?? 0,
|
|
193
|
+
entry.cachedTokens ?? 0,
|
|
194
|
+
entry.cacheCreationTokens ?? 0,
|
|
195
|
+
entry.costUSD,
|
|
196
|
+
entry.sessionId ?? null,
|
|
197
|
+
entry.userId ?? null,
|
|
198
|
+
entry.feature ?? null,
|
|
199
|
+
entry.appId ?? null,
|
|
200
|
+
entry.timestamp
|
|
201
|
+
]
|
|
202
|
+
).catch((err) => {
|
|
203
|
+
console.warn("[tokenwatch] MySQLStorage.record failed:", err);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async getAll() {
|
|
207
|
+
const [rows] = await this.client.execute(
|
|
208
|
+
"SELECT * FROM tokenwatch_usage ORDER BY timestamp ASC"
|
|
209
|
+
);
|
|
210
|
+
return rows.map(rowToEntry2);
|
|
211
|
+
}
|
|
212
|
+
async clearAll() {
|
|
213
|
+
await this.client.execute("DELETE FROM tokenwatch_usage");
|
|
214
|
+
}
|
|
215
|
+
async clearSession(sessionId) {
|
|
216
|
+
await this.client.execute(
|
|
217
|
+
"DELETE FROM tokenwatch_usage WHERE session_id = ?",
|
|
218
|
+
[sessionId]
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// src/adapters/mongodb.ts
|
|
226
|
+
var mongodb_exports = {};
|
|
227
|
+
__export(mongodb_exports, {
|
|
228
|
+
MongoStorage: () => MongoStorage
|
|
229
|
+
});
|
|
230
|
+
function docToEntry(doc) {
|
|
231
|
+
return {
|
|
232
|
+
model: doc.model,
|
|
233
|
+
inputTokens: doc.inputTokens,
|
|
234
|
+
outputTokens: doc.outputTokens,
|
|
235
|
+
...doc.reasoningTokens != null && doc.reasoningTokens > 0 && { reasoningTokens: doc.reasoningTokens },
|
|
236
|
+
...doc.cachedTokens != null && doc.cachedTokens > 0 && { cachedTokens: doc.cachedTokens },
|
|
237
|
+
...doc.cacheCreationTokens != null && doc.cacheCreationTokens > 0 && { cacheCreationTokens: doc.cacheCreationTokens },
|
|
238
|
+
costUSD: doc.costUSD,
|
|
239
|
+
...doc.sessionId != null && { sessionId: doc.sessionId },
|
|
240
|
+
...doc.userId != null && { userId: doc.userId },
|
|
241
|
+
...doc.feature != null && { feature: doc.feature },
|
|
242
|
+
...doc.appId != null && { appId: doc.appId },
|
|
243
|
+
timestamp: doc.timestamp
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
var COLLECTION, MongoStorage;
|
|
247
|
+
var init_mongodb = __esm({
|
|
248
|
+
"src/adapters/mongodb.ts"() {
|
|
249
|
+
"use strict";
|
|
250
|
+
COLLECTION = "tokenwatch_usage";
|
|
251
|
+
MongoStorage = class {
|
|
252
|
+
col;
|
|
253
|
+
constructor(db) {
|
|
254
|
+
this.col = db.collection(COLLECTION);
|
|
255
|
+
}
|
|
256
|
+
/** Creates recommended indexes for query performance. Call once at startup. */
|
|
257
|
+
async createIndexes() {
|
|
258
|
+
await this.col.createIndex({ timestamp: 1 });
|
|
259
|
+
await this.col.createIndex({ sessionId: 1 });
|
|
260
|
+
await this.col.createIndex({ userId: 1 });
|
|
261
|
+
await this.col.createIndex({ model: 1 });
|
|
262
|
+
await this.col.createIndex({ appId: 1 });
|
|
263
|
+
}
|
|
264
|
+
record(entry) {
|
|
265
|
+
this.col.insertOne({
|
|
266
|
+
model: entry.model,
|
|
267
|
+
inputTokens: entry.inputTokens,
|
|
268
|
+
outputTokens: entry.outputTokens,
|
|
269
|
+
...entry.reasoningTokens !== void 0 && { reasoningTokens: entry.reasoningTokens },
|
|
270
|
+
...entry.cachedTokens !== void 0 && { cachedTokens: entry.cachedTokens },
|
|
271
|
+
...entry.cacheCreationTokens !== void 0 && { cacheCreationTokens: entry.cacheCreationTokens },
|
|
272
|
+
costUSD: entry.costUSD,
|
|
273
|
+
sessionId: entry.sessionId ?? null,
|
|
274
|
+
userId: entry.userId ?? null,
|
|
275
|
+
...entry.feature !== void 0 && { feature: entry.feature },
|
|
276
|
+
...entry.appId !== void 0 && { appId: entry.appId },
|
|
277
|
+
timestamp: entry.timestamp
|
|
278
|
+
}).catch((err) => {
|
|
279
|
+
console.warn("[tokenwatch] MongoStorage.record failed:", err);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async getAll() {
|
|
283
|
+
const docs = await this.col.find({}).sort({ timestamp: 1 }).toArray();
|
|
284
|
+
return docs.map(docToEntry);
|
|
285
|
+
}
|
|
286
|
+
async clearAll() {
|
|
287
|
+
await this.col.deleteMany({});
|
|
288
|
+
}
|
|
289
|
+
async clearSession(sessionId) {
|
|
290
|
+
await this.col.deleteMany({ sessionId });
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
});
|
|
2
295
|
|
|
3
296
|
// bin/cli.ts
|
|
4
297
|
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
@@ -110,6 +403,7 @@ var SqliteStorage = class {
|
|
|
110
403
|
session_id TEXT,
|
|
111
404
|
user_id TEXT,
|
|
112
405
|
feature TEXT,
|
|
406
|
+
app_id TEXT,
|
|
113
407
|
timestamp TEXT NOT NULL
|
|
114
408
|
)
|
|
115
409
|
`);
|
|
@@ -126,13 +420,16 @@ var SqliteStorage = class {
|
|
|
126
420
|
if (!cols.includes("cache_creation_tokens")) {
|
|
127
421
|
this.db.exec(`ALTER TABLE usage ADD COLUMN cache_creation_tokens INTEGER NOT NULL DEFAULT 0`);
|
|
128
422
|
}
|
|
423
|
+
if (!cols.includes("app_id")) {
|
|
424
|
+
this.db.exec(`ALTER TABLE usage ADD COLUMN app_id TEXT`);
|
|
425
|
+
}
|
|
129
426
|
}
|
|
130
427
|
record(entry) {
|
|
131
428
|
this.db.prepare(
|
|
132
429
|
`INSERT INTO usage
|
|
133
430
|
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
134
|
-
cost_usd, session_id, user_id, feature, timestamp)
|
|
135
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
431
|
+
cost_usd, session_id, user_id, feature, app_id, timestamp)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
136
433
|
).run(
|
|
137
434
|
entry.model,
|
|
138
435
|
entry.inputTokens,
|
|
@@ -144,6 +441,7 @@ var SqliteStorage = class {
|
|
|
144
441
|
entry.sessionId ?? null,
|
|
145
442
|
entry.userId ?? null,
|
|
146
443
|
entry.feature ?? null,
|
|
444
|
+
entry.appId ?? null,
|
|
147
445
|
entry.timestamp
|
|
148
446
|
);
|
|
149
447
|
}
|
|
@@ -160,6 +458,7 @@ var SqliteStorage = class {
|
|
|
160
458
|
...r.session_id != null && { sessionId: r.session_id },
|
|
161
459
|
...r.user_id != null && { userId: r.user_id },
|
|
162
460
|
...r.feature != null && { feature: r.feature },
|
|
461
|
+
...r.app_id != null && { appId: r.app_id },
|
|
163
462
|
timestamp: r.timestamp
|
|
164
463
|
}));
|
|
165
464
|
}
|
|
@@ -245,7 +544,7 @@ function maybeSuggestCheaperModel(model, costUSD, inputTokens, outputTokens, lay
|
|
|
245
544
|
|
|
246
545
|
// prices.json
|
|
247
546
|
var prices_default = {
|
|
248
|
-
updated_at: "2026-
|
|
547
|
+
updated_at: "2026-06-09",
|
|
249
548
|
source: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
|
|
250
549
|
models: {
|
|
251
550
|
"gpt-4o": {
|
|
@@ -289,14 +588,12 @@ var prices_default = {
|
|
|
289
588
|
input: 3,
|
|
290
589
|
output: 15,
|
|
291
590
|
cachedInput: 0.3,
|
|
292
|
-
|
|
293
|
-
maxInputTokens: 1e6
|
|
591
|
+
maxInputTokens: 2e5
|
|
294
592
|
},
|
|
295
593
|
"claude-haiku-4-5": {
|
|
296
594
|
input: 1,
|
|
297
595
|
output: 5,
|
|
298
596
|
cachedInput: 0.1,
|
|
299
|
-
cacheCreationInput: 1.25,
|
|
300
597
|
maxInputTokens: 2e5
|
|
301
598
|
},
|
|
302
599
|
"gemini-2.5-pro": {
|
|
@@ -348,7 +645,6 @@ var prices_default = {
|
|
|
348
645
|
input: 3,
|
|
349
646
|
output: 15,
|
|
350
647
|
cachedInput: 0.3,
|
|
351
|
-
cacheCreationInput: 3.75,
|
|
352
648
|
maxInputTokens: 2e5
|
|
353
649
|
},
|
|
354
650
|
"gpt-oss-120b": {
|
|
@@ -841,9 +1137,9 @@ var prices_default = {
|
|
|
841
1137
|
maxInputTokens: 163840
|
|
842
1138
|
},
|
|
843
1139
|
"deepseek-r1": {
|
|
844
|
-
input:
|
|
845
|
-
output:
|
|
846
|
-
maxInputTokens:
|
|
1140
|
+
input: 1.35,
|
|
1141
|
+
output: 5.4,
|
|
1142
|
+
maxInputTokens: 128e3
|
|
847
1143
|
},
|
|
848
1144
|
"deepseek-v3": {
|
|
849
1145
|
input: 0.27,
|
|
@@ -1283,7 +1579,7 @@ var prices_default = {
|
|
|
1283
1579
|
"deepseek-r1-distill-llama-70b": {
|
|
1284
1580
|
input: 0.99,
|
|
1285
1581
|
output: 0.99,
|
|
1286
|
-
maxInputTokens:
|
|
1582
|
+
maxInputTokens: 32768
|
|
1287
1583
|
},
|
|
1288
1584
|
"deepseek-llama3.3-70b": {
|
|
1289
1585
|
input: 0.2,
|
|
@@ -1562,6 +1858,102 @@ var prices_default = {
|
|
|
1562
1858
|
cachedInput: 0.3,
|
|
1563
1859
|
cacheCreationInput: 3.75,
|
|
1564
1860
|
maxInputTokens: 1e6
|
|
1861
|
+
},
|
|
1862
|
+
"gpt-5.5": {
|
|
1863
|
+
input: 5,
|
|
1864
|
+
output: 30,
|
|
1865
|
+
cachedInput: 0.5,
|
|
1866
|
+
maxInputTokens: 105e4
|
|
1867
|
+
},
|
|
1868
|
+
"gpt-5.5-2026-04-23": {
|
|
1869
|
+
input: 5,
|
|
1870
|
+
output: 30,
|
|
1871
|
+
cachedInput: 0.5,
|
|
1872
|
+
maxInputTokens: 105e4
|
|
1873
|
+
},
|
|
1874
|
+
"gpt-5.5-pro": {
|
|
1875
|
+
input: 30,
|
|
1876
|
+
output: 180,
|
|
1877
|
+
cachedInput: 3,
|
|
1878
|
+
maxInputTokens: 105e4
|
|
1879
|
+
},
|
|
1880
|
+
"gpt-5.5-pro-2026-04-23": {
|
|
1881
|
+
input: 30,
|
|
1882
|
+
output: 180,
|
|
1883
|
+
cachedInput: 3,
|
|
1884
|
+
maxInputTokens: 105e4
|
|
1885
|
+
},
|
|
1886
|
+
"gpt-5.4-mini-2026-03-17": {
|
|
1887
|
+
input: 0.75,
|
|
1888
|
+
output: 4.5,
|
|
1889
|
+
cachedInput: 0.075,
|
|
1890
|
+
maxInputTokens: 272e3
|
|
1891
|
+
},
|
|
1892
|
+
"gpt-5.4-nano-2026-03-17": {
|
|
1893
|
+
input: 0.2,
|
|
1894
|
+
output: 1.25,
|
|
1895
|
+
cachedInput: 0.02,
|
|
1896
|
+
maxInputTokens: 272e3
|
|
1897
|
+
},
|
|
1898
|
+
"gpt-image-2": {
|
|
1899
|
+
input: 5,
|
|
1900
|
+
output: 10,
|
|
1901
|
+
cachedInput: 1.25
|
|
1902
|
+
},
|
|
1903
|
+
"gpt-image-2-2026-04-21": {
|
|
1904
|
+
input: 5,
|
|
1905
|
+
output: 10,
|
|
1906
|
+
cachedInput: 1.25
|
|
1907
|
+
},
|
|
1908
|
+
"gpt-realtime-2": {
|
|
1909
|
+
input: 4,
|
|
1910
|
+
output: 16,
|
|
1911
|
+
cachedInput: 0.4,
|
|
1912
|
+
maxInputTokens: 32e3
|
|
1913
|
+
},
|
|
1914
|
+
"gemini-3.5-flash": {
|
|
1915
|
+
input: 1.5,
|
|
1916
|
+
output: 9,
|
|
1917
|
+
cachedInput: 0.15,
|
|
1918
|
+
maxInputTokens: 1048576
|
|
1919
|
+
},
|
|
1920
|
+
"gemini-3.1-flash-lite": {
|
|
1921
|
+
input: 0.25,
|
|
1922
|
+
output: 1.5,
|
|
1923
|
+
cachedInput: 0.025,
|
|
1924
|
+
maxInputTokens: 1048576
|
|
1925
|
+
},
|
|
1926
|
+
"claude-opus-4-8": {
|
|
1927
|
+
input: 5,
|
|
1928
|
+
output: 25,
|
|
1929
|
+
cachedInput: 0.5,
|
|
1930
|
+
cacheCreationInput: 6.25,
|
|
1931
|
+
maxInputTokens: 1e6
|
|
1932
|
+
},
|
|
1933
|
+
"claude-opus-4-8@default": {
|
|
1934
|
+
input: 5,
|
|
1935
|
+
output: 25,
|
|
1936
|
+
cachedInput: 0.5,
|
|
1937
|
+
cacheCreationInput: 6.25,
|
|
1938
|
+
maxInputTokens: 1e6
|
|
1939
|
+
},
|
|
1940
|
+
"claude-4-sonnet": {
|
|
1941
|
+
input: 3,
|
|
1942
|
+
output: 15,
|
|
1943
|
+
cachedInput: 0.3,
|
|
1944
|
+
maxInputTokens: 2e5
|
|
1945
|
+
},
|
|
1946
|
+
"claude-4-opus": {
|
|
1947
|
+
input: 5,
|
|
1948
|
+
output: 25,
|
|
1949
|
+
cachedInput: 0.5,
|
|
1950
|
+
maxInputTokens: 2e5
|
|
1951
|
+
},
|
|
1952
|
+
"claude-3-7-sonnet": {
|
|
1953
|
+
input: 3,
|
|
1954
|
+
output: 15,
|
|
1955
|
+
cachedInput: 0.3,
|
|
1956
|
+
maxInputTokens: 2e5
|
|
1565
1957
|
}
|
|
1566
1958
|
}
|
|
1567
1959
|
};
|
|
@@ -1601,7 +1993,8 @@ var TrackerConfigSchema = z.object({
|
|
|
1601
1993
|
windowHours: z.number().positive().optional().default(24),
|
|
1602
1994
|
mode: z.enum(["once", "always"]).optional().default("once")
|
|
1603
1995
|
}).optional(),
|
|
1604
|
-
exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional()
|
|
1996
|
+
exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional(),
|
|
1997
|
+
appId: z.string().optional()
|
|
1605
1998
|
});
|
|
1606
1999
|
function createTracker(config = {}) {
|
|
1607
2000
|
const parsed = TrackerConfigSchema.safeParse(config);
|
|
@@ -1620,7 +2013,8 @@ ${issues}`);
|
|
|
1620
2013
|
budgets,
|
|
1621
2014
|
suggestions,
|
|
1622
2015
|
anomalyDetection,
|
|
1623
|
-
exporter
|
|
2016
|
+
exporter,
|
|
2017
|
+
appId
|
|
1624
2018
|
} = parsed.data;
|
|
1625
2019
|
const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
|
|
1626
2020
|
let remotePrices;
|
|
@@ -1675,7 +2069,8 @@ ${issues}`);
|
|
|
1675
2069
|
const full = {
|
|
1676
2070
|
...entry,
|
|
1677
2071
|
costUSD,
|
|
1678
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2072
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2073
|
+
...appId !== void 0 && entry.appId === void 0 && { appId }
|
|
1679
2074
|
};
|
|
1680
2075
|
storage.record(full);
|
|
1681
2076
|
if (exporter) {
|
|
@@ -1762,6 +2157,7 @@ ${issues}`);
|
|
|
1762
2157
|
const bySession = {};
|
|
1763
2158
|
const byUser = {};
|
|
1764
2159
|
const byFeature = {};
|
|
2160
|
+
const byApp = {};
|
|
1765
2161
|
let totalInput = 0;
|
|
1766
2162
|
let totalOutput = 0;
|
|
1767
2163
|
let totalCost = 0;
|
|
@@ -1798,6 +2194,11 @@ ${issues}`);
|
|
|
1798
2194
|
f.costUSD += e.costUSD;
|
|
1799
2195
|
f.calls += 1;
|
|
1800
2196
|
}
|
|
2197
|
+
if (e.appId) {
|
|
2198
|
+
const a = byApp[e.appId] ??= { costUSD: 0, calls: 0 };
|
|
2199
|
+
a.costUSD += e.costUSD;
|
|
2200
|
+
a.calls += 1;
|
|
2201
|
+
}
|
|
1801
2202
|
}
|
|
1802
2203
|
if (options && entries.length > 0) {
|
|
1803
2204
|
periodFrom = entries[0]?.timestamp ?? periodFrom;
|
|
@@ -1809,6 +2210,7 @@ ${issues}`);
|
|
|
1809
2210
|
bySession,
|
|
1810
2211
|
byUser,
|
|
1811
2212
|
byFeature,
|
|
2213
|
+
byApp,
|
|
1812
2214
|
period: { from: periodFrom, to: lastTimestamp },
|
|
1813
2215
|
...pricesUpdatedAt ? { pricesUpdatedAt } : {}
|
|
1814
2216
|
};
|
|
@@ -1913,7 +2315,7 @@ ${issues}`);
|
|
|
1913
2315
|
}
|
|
1914
2316
|
async function exportCSV() {
|
|
1915
2317
|
const entries = await Promise.resolve(storage.getAll());
|
|
1916
|
-
const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature";
|
|
2318
|
+
const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature,appId";
|
|
1917
2319
|
const rows = entries.map(
|
|
1918
2320
|
(e) => [
|
|
1919
2321
|
csvEscape(e.timestamp),
|
|
@@ -1926,7 +2328,8 @@ ${issues}`);
|
|
|
1926
2328
|
e.costUSD.toFixed(8),
|
|
1927
2329
|
csvEscape(e.sessionId ?? ""),
|
|
1928
2330
|
csvEscape(e.userId ?? ""),
|
|
1929
|
-
csvEscape(e.feature ?? "")
|
|
2331
|
+
csvEscape(e.feature ?? ""),
|
|
2332
|
+
csvEscape(e.appId ?? "")
|
|
1930
2333
|
].join(",")
|
|
1931
2334
|
);
|
|
1932
2335
|
return [header, ...rows].join("\n");
|
|
@@ -2044,6 +2447,7 @@ async function getDashboardData(storage, filter) {
|
|
|
2044
2447
|
const bySession = {};
|
|
2045
2448
|
const byUser = {};
|
|
2046
2449
|
const byFeature = {};
|
|
2450
|
+
const byApp = {};
|
|
2047
2451
|
let totalInput = 0;
|
|
2048
2452
|
let totalOutput = 0;
|
|
2049
2453
|
let totalCost = 0;
|
|
@@ -2077,6 +2481,11 @@ async function getDashboardData(storage, filter) {
|
|
|
2077
2481
|
f.costUSD += e.costUSD;
|
|
2078
2482
|
f.calls += 1;
|
|
2079
2483
|
}
|
|
2484
|
+
if (e.appId) {
|
|
2485
|
+
const a = byApp[e.appId] ??= { costUSD: 0, calls: 0 };
|
|
2486
|
+
a.costUSD += e.costUSD;
|
|
2487
|
+
a.calls += 1;
|
|
2488
|
+
}
|
|
2080
2489
|
}
|
|
2081
2490
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2082
2491
|
const periodFrom = entries[0]?.timestamp ?? now;
|
|
@@ -2088,6 +2497,7 @@ async function getDashboardData(storage, filter) {
|
|
|
2088
2497
|
bySession,
|
|
2089
2498
|
byUser,
|
|
2090
2499
|
byFeature,
|
|
2500
|
+
byApp,
|
|
2091
2501
|
period: { from: periodFrom, to: periodTo }
|
|
2092
2502
|
};
|
|
2093
2503
|
const forecastWindowMs = 24 * 60 * 60 * 1e3;
|
|
@@ -2134,555 +2544,1495 @@ async function getDashboardData(storage, filter) {
|
|
|
2134
2544
|
}
|
|
2135
2545
|
|
|
2136
2546
|
// src/dashboard/html.ts
|
|
2137
|
-
function getHtml(
|
|
2138
|
-
void port;
|
|
2547
|
+
function getHtml(_port) {
|
|
2139
2548
|
return `<!DOCTYPE html>
|
|
2140
2549
|
<html lang="en">
|
|
2141
2550
|
<head>
|
|
2142
2551
|
<meta charset="UTF-8" />
|
|
2143
2552
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2144
|
-
<title>tokenwatch
|
|
2145
|
-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
2553
|
+
<title>tokenwatch \u2014 Overview</title>
|
|
2146
2554
|
<style>
|
|
2147
|
-
|
|
2555
|
+
:root{
|
|
2556
|
+
--bg:#0d1117; --surface:#161b22; --surface2:#1c2128;
|
|
2557
|
+
--border:#30363d; --border-muted:#21262d;
|
|
2558
|
+
--text:#e6edf3; --muted:#7d8590; --dim:#484f58;
|
|
2559
|
+
--accent:#58a6ff; --green:#3fb950; --yellow:#d29922; --red:#f85149;
|
|
2560
|
+
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
|
2561
|
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
|
2562
|
+
}
|
|
2563
|
+
*{box-sizing:border-box}
|
|
2564
|
+
html,body{margin:0}
|
|
2565
|
+
body{background:var(--bg);color:var(--text);font-family:var(--sans);
|
|
2566
|
+
font-size:13px;line-height:1.45;-webkit-font-smoothing:antialiased;
|
|
2567
|
+
font-variant-numeric:tabular-nums}
|
|
2568
|
+
h1,h2,h3{margin:0;font-weight:600}
|
|
2569
|
+
a{color:inherit;text-decoration:none}
|
|
2570
|
+
kbd{font-family:var(--sans);font-size:10px;color:var(--muted);
|
|
2571
|
+
background:#22272e;border:1px solid var(--border);border-radius:4px;
|
|
2572
|
+
padding:1px 5px;line-height:1.4}
|
|
2573
|
+
.tw-root{max-width:1440px;margin:0 auto;min-height:100vh;
|
|
2574
|
+
border-left:1px solid var(--border-muted);border-right:1px solid var(--border-muted)}
|
|
2575
|
+
.num,.tw-kpi-value,.tw-cost,.tw-num-cell,.tw-fc-value{font-variant-numeric:tabular-nums}
|
|
2576
|
+
.tw-header{position:sticky;top:0;z-index:40;height:54px;display:flex;
|
|
2577
|
+
align-items:center;justify-content:space-between;gap:16px;padding:0 18px;
|
|
2578
|
+
background:rgba(13,17,23,.86);backdrop-filter:blur(10px);
|
|
2579
|
+
border-bottom:1px solid var(--border)}
|
|
2580
|
+
.tw-hgroup{display:flex;align-items:center;gap:14px}
|
|
2581
|
+
.tw-logo{font-size:15px;font-weight:700;letter-spacing:-.01em}
|
|
2582
|
+
.tw-logo span{color:var(--accent)}
|
|
2583
|
+
.tw-ws{position:relative;display:flex;align-items:center;gap:7px;height:30px;
|
|
2584
|
+
padding:0 9px;background:var(--surface);border:1px solid var(--border);
|
|
2585
|
+
border-radius:7px;color:var(--text);font-size:12.5px;font-weight:500;cursor:pointer}
|
|
2586
|
+
.tw-ws:hover{border-color:#444c56}
|
|
2587
|
+
.tw-ws-dot{width:7px;height:7px;border-radius:50%;background:var(--green);
|
|
2588
|
+
box-shadow:0 0 0 3px rgba(63,185,80,.15)}
|
|
2589
|
+
.tw-ws-env{font-size:10px;color:var(--muted);background:#22272e;
|
|
2590
|
+
border-radius:4px;padding:1px 5px;text-transform:uppercase;letter-spacing:.03em}
|
|
2591
|
+
.tw-ws-menu{position:absolute;top:36px;left:0;width:230px;background:var(--surface2);
|
|
2592
|
+
border:1px solid var(--border);border-radius:9px;padding:5px;z-index:50;
|
|
2593
|
+
box-shadow:0 12px 32px rgba(0,0,0,.5)}
|
|
2594
|
+
.tw-ws-item{padding:7px 9px;border-radius:6px;font-size:12.5px;cursor:pointer;color:var(--text)}
|
|
2595
|
+
.tw-ws-item:hover{background:#22272e}
|
|
2596
|
+
.tw-ws-item.on{color:var(--accent)}
|
|
2597
|
+
.tw-ws-item.muted{color:var(--muted)}
|
|
2598
|
+
.tw-ws-sep{height:1px;background:var(--border);margin:5px 0}
|
|
2599
|
+
.tw-nav{display:flex;gap:2px;margin-left:6px}
|
|
2600
|
+
.tw-nav a{padding:6px 10px;border-radius:6px;font-size:13px;color:var(--muted);font-weight:500}
|
|
2601
|
+
.tw-nav a:hover{color:var(--text);background:var(--surface)}
|
|
2602
|
+
.tw-nav a.on{color:var(--text);background:var(--surface);box-shadow:inset 0 -2px 0 var(--accent)}
|
|
2603
|
+
.tw-search{display:flex;align-items:center;gap:8px;height:30px;padding:0 9px;
|
|
2604
|
+
width:200px;background:var(--surface);border:1px solid var(--border);
|
|
2605
|
+
border-radius:7px;color:var(--muted);font-size:12.5px;cursor:text}
|
|
2606
|
+
.tw-search:hover{border-color:#444c56}
|
|
2607
|
+
.tw-search span{flex:1;text-align:left}
|
|
2608
|
+
.tw-btn-2{display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;
|
|
2609
|
+
background:var(--surface);border:1px solid var(--border);border-radius:7px;
|
|
2610
|
+
color:var(--text);font-size:12.5px;font-weight:500;cursor:pointer}
|
|
2611
|
+
.tw-btn-2:hover{border-color:#444c56;background:#1b2128}
|
|
2612
|
+
.tw-user{display:flex;align-items:center;gap:8px;margin-left:2px}
|
|
2613
|
+
.tw-plan{font-size:10px;font-weight:600;color:var(--accent);
|
|
2614
|
+
border:1px solid rgba(88,166,255,.4);background:rgba(88,166,255,.1);
|
|
2615
|
+
border-radius:5px;padding:2px 6px;text-transform:uppercase;letter-spacing:.04em}
|
|
2616
|
+
.tw-avatar{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;
|
|
2617
|
+
font-size:11px;font-weight:600;color:#fff;
|
|
2618
|
+
background:linear-gradient(135deg,#bc8cff,#58a6ff)}
|
|
2619
|
+
.tw-main{padding:16px 18px 64px;display:flex;flex-direction:column;gap:14px}
|
|
2620
|
+
.tw-budget{background:var(--surface);border:1px solid var(--border);
|
|
2621
|
+
border-radius:10px;padding:13px 16px}
|
|
2622
|
+
.tw-budget-top{display:flex;align-items:center;gap:16px;margin-bottom:10px}
|
|
2623
|
+
.tw-budget-label{display:flex;align-items:baseline;gap:10px}
|
|
2624
|
+
.tw-bud-strong{font-weight:600;font-size:13px}
|
|
2625
|
+
.tw-bud-nums{color:var(--muted);font-size:12.5px}
|
|
2626
|
+
.tw-bud-nums b{color:var(--text)}
|
|
2627
|
+
.tw-bud-alert{display:flex;align-items:center;gap:6px;font-size:12px;
|
|
2628
|
+
padding:3px 9px;border-radius:6px;color:var(--green);
|
|
2629
|
+
background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25)}
|
|
2630
|
+
.tw-bud-alert b{color:var(--text)}
|
|
2631
|
+
.tw-budget-days{margin-left:auto;color:var(--muted);font-size:12px}
|
|
2632
|
+
.tw-bar{position:relative;height:9px;background:var(--border-muted);
|
|
2633
|
+
border-radius:5px;overflow:visible}
|
|
2634
|
+
.tw-bar-fill{height:100%;border-radius:5px;transition:width .5s}
|
|
2635
|
+
.tw-bar-proj{position:absolute;top:0;height:100%;
|
|
2636
|
+
background:repeating-linear-gradient(45deg,rgba(125,133,144,.35),rgba(125,133,144,.35) 4px,transparent 4px,transparent 8px);
|
|
2637
|
+
border-radius:0 5px 5px 0}
|
|
2638
|
+
.tw-bar-marker{position:absolute;top:-3px;width:2px;height:15px;background:var(--text);border-radius:2px}
|
|
2639
|
+
.tw-bar-marker::after{content:attr(data-tip);position:absolute;top:-18px;left:50%;
|
|
2640
|
+
transform:translateX(-50%);font-size:9.5px;color:var(--muted);white-space:nowrap}
|
|
2641
|
+
.tw-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
|
|
2642
|
+
.tw-kpi{background:var(--surface);border:1px solid var(--border);border-radius:10px;
|
|
2643
|
+
padding:12px 14px;display:flex;flex-direction:column;gap:7px;min-width:0}
|
|
2644
|
+
.tw-kpi-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
2645
|
+
.tw-kpi-main{display:flex;align-items:flex-end;justify-content:space-between;gap:8px}
|
|
2646
|
+
.tw-kpi-value{font-size:21px;font-weight:600;letter-spacing:-.01em;
|
|
2647
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2648
|
+
.tw-kpi-foot{display:flex;align-items:center;gap:8px;font-size:11.5px;min-height:15px}
|
|
2649
|
+
.tw-delta{font-weight:600}
|
|
2650
|
+
.tw-kpi-sub{color:var(--muted)}
|
|
2651
|
+
.tw-timefilter{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 2px}
|
|
2652
|
+
.tw-tabs{display:flex;gap:2px;background:var(--surface);border:1px solid var(--border);
|
|
2653
|
+
border-radius:8px;padding:3px}
|
|
2654
|
+
.tw-tab{height:26px;padding:0 13px;border:0;background:transparent;color:var(--muted);
|
|
2655
|
+
font-size:12.5px;font-weight:600;font-family:var(--sans);border-radius:6px;cursor:pointer}
|
|
2656
|
+
.tw-tab:hover{color:var(--text)}
|
|
2657
|
+
.tw-tab.on{background:var(--accent);color:#0d1117}
|
|
2658
|
+
.tw-compare{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12.5px;cursor:pointer}
|
|
2659
|
+
.tw-toggle-sm{position:relative;width:30px;height:17px;border:0;border-radius:999px;
|
|
2660
|
+
background:#30363d;cursor:pointer;padding:0;transition:background .15s}
|
|
2661
|
+
.tw-toggle-sm[data-on="1"]{background:var(--accent)}
|
|
2662
|
+
.tw-toggle-sm i{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;
|
|
2663
|
+
background:#fff;transition:transform .15s}
|
|
2664
|
+
.tw-toggle-sm[data-on="1"] i{transform:translateX(13px)}
|
|
2665
|
+
.tw-card,.tw-section{background:var(--surface);border:1px solid var(--border);border-radius:10px}
|
|
2666
|
+
.tw-section{padding:0}
|
|
2667
|
+
.tw-card{padding:14px 16px}
|
|
2668
|
+
.tw-sec-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
|
2669
|
+
padding:13px 16px 11px}
|
|
2670
|
+
.tw-card .tw-sec-head{padding:0 0 10px}
|
|
2671
|
+
.tw-sec-head h3{font-size:13.5px}
|
|
2672
|
+
.tw-sec-sub{color:var(--muted);font-size:11.5px;font-weight:400}
|
|
2673
|
+
.tw-charts{display:grid;grid-template-columns:2fr 1fr;gap:14px}
|
|
2674
|
+
.tw-chart-main,.tw-chart-side{min-width:0}
|
|
2675
|
+
.tw-chart-legend{display:flex;align-items:center;gap:12px;font-size:11.5px;color:var(--muted)}
|
|
2676
|
+
.tw-chart-legend span{display:flex;align-items:center;gap:5px}
|
|
2677
|
+
.tw-chart-legend i{width:14px;height:3px;border-radius:2px;background:var(--accent)}
|
|
2678
|
+
.tw-chart-legend i.dash{background:repeating-linear-gradient(90deg,#6e7681,#6e7681 3px,transparent 3px,transparent 6px)}
|
|
2679
|
+
.tw-chart-legend .muted i{background:#6e7681}
|
|
2680
|
+
.tw-draw-line{stroke-dasharray:2600;stroke-dashoffset:2600;animation:tw-draw 1.1s cubic-bezier(.4,0,.2,1) forwards}
|
|
2681
|
+
.tw-draw-area{opacity:0;animation:tw-fade .9s ease .25s forwards}
|
|
2682
|
+
@keyframes tw-draw{to{stroke-dashoffset:0}}
|
|
2683
|
+
@keyframes tw-fade{to{opacity:1}}
|
|
2684
|
+
.tw-doughnut-wrap{display:grid;place-items:center;padding:6px 0 10px}
|
|
2685
|
+
.tw-legend{display:flex;flex-direction:column;gap:1px}
|
|
2686
|
+
.tw-legend-row{display:flex;align-items:center;gap:8px;padding:5px 7px;border-radius:6px;cursor:pointer}
|
|
2687
|
+
.tw-legend-row:hover,.tw-legend-row.on{background:#1b2128}
|
|
2688
|
+
.tw-legend-name{flex:1;font-size:12px;font-family:var(--mono);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2689
|
+
.tw-legend-val{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2690
|
+
.tw-legend-pct{font-size:11px;color:var(--dim);width:30px;text-align:right;font-variant-numeric:tabular-nums}
|
|
2691
|
+
.tw-model-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
|
|
2692
|
+
.tw-model-dot.lg{width:11px;height:11px;border-radius:3px}
|
|
2693
|
+
.tw-table-wrap{overflow-x:auto}
|
|
2694
|
+
.tw-table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
2695
|
+
.tw-table thead th{position:sticky;top:0;text-align:left;padding:8px 16px;
|
|
2696
|
+
font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;
|
|
2697
|
+
letter-spacing:.04em;border-bottom:1px solid var(--border);user-select:none;
|
|
2698
|
+
background:var(--surface)}
|
|
2699
|
+
.tw-th-inner{display:flex;align-items:center;gap:4px}
|
|
2700
|
+
.tw-th-inner:hover{color:var(--text)}
|
|
2701
|
+
.tw-sort{color:var(--accent);font-size:10px}
|
|
2702
|
+
.tw-row{border-bottom:1px solid var(--border-muted);cursor:pointer;transition:background .1s}
|
|
2703
|
+
.tw-row:last-child{border-bottom:0}
|
|
2704
|
+
.tw-row td{padding:10px 16px;vertical-align:middle}
|
|
2705
|
+
.tw-table.compact .tw-row td{padding:6px 16px}
|
|
2706
|
+
.tw-table.compact thead th{padding:6px 16px}
|
|
2707
|
+
.tw-row:hover{background:#1b2128}
|
|
2708
|
+
.tw-row.example{background:rgba(88,166,255,.07);box-shadow:inset 2px 0 0 var(--accent)}
|
|
2709
|
+
.tw-row.example:hover{background:rgba(88,166,255,.11)}
|
|
2710
|
+
.tw-model-cell{display:flex;align-items:center;gap:9px}
|
|
2711
|
+
.tw-model-name{font-family:var(--mono);font-size:12.5px;color:var(--text)}
|
|
2712
|
+
.tw-model-prov{font-size:10px;color:var(--muted);background:#22272e;border-radius:4px;padding:1px 5px}
|
|
2713
|
+
.tw-num-cell{text-align:right;font-variant-numeric:tabular-nums;color:var(--text);white-space:nowrap}
|
|
2714
|
+
.tw-cost{font-weight:600}
|
|
2715
|
+
.tw-row-actions{display:flex;gap:6px;justify-content:flex-end}
|
|
2716
|
+
.tw-row-actions button{height:24px;padding:0 9px;border:1px solid var(--border);
|
|
2717
|
+
background:var(--surface2);color:var(--text);border-radius:6px;font-size:11px;
|
|
2718
|
+
font-weight:500;cursor:pointer;font-family:var(--sans)}
|
|
2719
|
+
.tw-row-actions button:hover{border-color:var(--accent);color:var(--accent)}
|
|
2720
|
+
.tw-forecast{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:0 16px 16px}
|
|
2721
|
+
.tw-fc{background:var(--surface2);border:1px solid var(--border);border-radius:9px;
|
|
2722
|
+
padding:12px 14px;display:flex;flex-direction:column;gap:6px}
|
|
2723
|
+
.tw-fc.flag{border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.07)}
|
|
2724
|
+
.tw-fc-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}
|
|
2725
|
+
.tw-fc-value{font-size:20px;font-weight:600;letter-spacing:-.01em}
|
|
2726
|
+
.tw-fc-sub{font-size:11.5px;color:var(--muted)}
|
|
2727
|
+
.tw-act-title{display:flex;align-items:center;gap:9px}
|
|
2728
|
+
.tw-collapse{border:0;background:transparent;color:var(--muted);cursor:pointer;
|
|
2729
|
+
display:grid;place-items:center;padding:2px;transition:transform .15s}
|
|
2730
|
+
.tw-live-dot{width:8px;height:8px;border-radius:50%;background:var(--dim)}
|
|
2731
|
+
.tw-live-dot.on{background:var(--green);animation:tw-pulse 1.6s ease-in-out infinite}
|
|
2732
|
+
@keyframes tw-pulse{0%,100%{box-shadow:0 0 0 0 rgba(63,185,80,.5)}50%{box-shadow:0 0 0 5px rgba(63,185,80,0)}}
|
|
2733
|
+
.tw-act-controls{display:flex;align-items:center;gap:10px}
|
|
2734
|
+
.tw-seg-sm{display:flex;gap:1px;background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:2px}
|
|
2735
|
+
.tw-seg-sm button{height:22px;padding:0 9px;border:0;background:transparent;color:var(--muted);
|
|
2736
|
+
font-size:11px;font-weight:600;border-radius:5px;cursor:pointer;font-family:var(--sans)}
|
|
2737
|
+
.tw-seg-sm button.on{background:var(--border);color:var(--text)}
|
|
2738
|
+
.tw-act-pause{height:26px;padding:0 10px;border:1px solid var(--border);background:var(--surface2);
|
|
2739
|
+
color:var(--muted);border-radius:6px;font-size:11.5px;cursor:pointer;font-family:var(--sans)}
|
|
2740
|
+
.tw-act-pause:hover{color:var(--text);border-color:#444c56}
|
|
2741
|
+
.tw-feed{padding:0 6px 8px}
|
|
2742
|
+
.tw-feed-head,.tw-feed-row{display:grid;grid-template-columns:72px 1.4fr 1fr 1fr 96px;gap:12px;align-items:center}
|
|
2743
|
+
.tw-feed-head{padding:6px 10px;font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);border-bottom:1px solid var(--border-muted)}
|
|
2744
|
+
.tw-feed-body{display:flex;flex-direction:column}
|
|
2745
|
+
.tw-feed-row{padding:7px 10px;font-size:12px;border-bottom:1px solid var(--border-muted)}
|
|
2746
|
+
.tw-feed-row:last-child{border-bottom:0}
|
|
2747
|
+
.tw-feed-row.fresh{animation:tw-flash 1.4s ease}
|
|
2748
|
+
.tw-feed-row.err{background:rgba(248,81,73,.05)}
|
|
2749
|
+
@keyframes tw-flash{0%{background:rgba(88,166,255,.16)}100%{background:transparent}}
|
|
2750
|
+
.tw-feed-time{color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2751
|
+
.tw-feed-model{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11.5px;color:var(--text);overflow:hidden}
|
|
2752
|
+
.tw-feed-sess{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
|
2753
|
+
.tw-feed-cost{text-align:right;font-variant-numeric:tabular-nums;color:var(--text);font-weight:500;display:flex;align-items:center;justify-content:flex-end;gap:6px}
|
|
2754
|
+
.tw-feed-flag{font-size:9px;color:var(--red);border:1px solid rgba(248,81,73,.4);border-radius:4px;padding:0 4px;text-transform:uppercase}
|
|
2755
|
+
.tw-feed-flag.slow{color:var(--yellow);border-color:rgba(210,153,34,.4)}
|
|
2756
|
+
.tw-feat{font-size:10.5px;font-family:var(--mono);border:1px solid;border-radius:5px;padding:1px 6px;white-space:nowrap}
|
|
2757
|
+
.tw-feat.sm{font-size:10px;padding:0 5px}
|
|
2758
|
+
.tw-scrim{position:fixed;inset:0;background:rgba(1,4,9,.6);opacity:0;pointer-events:none;
|
|
2759
|
+
transition:opacity .25s;z-index:60}
|
|
2760
|
+
.tw-scrim.open{opacity:1;pointer-events:auto}
|
|
2761
|
+
.tw-slideover{position:fixed;top:0;right:0;height:100vh;width:400px;background:var(--surface);
|
|
2762
|
+
border-left:1px solid var(--border);transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
|
|
2763
|
+
z-index:61;box-shadow:-16px 0 40px rgba(0,0,0,.4);overflow-y:auto}
|
|
2764
|
+
.tw-slideover.open{transform:translateX(0)}
|
|
2765
|
+
.tw-so-inner{padding:18px}
|
|
2766
|
+
.tw-so-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:16px}
|
|
2767
|
+
.tw-so-title{display:flex;align-items:center;gap:10px}
|
|
2768
|
+
.tw-so-name{font-family:var(--mono);font-size:15px;font-weight:600}
|
|
2769
|
+
.tw-so-prov{font-size:11.5px;color:var(--muted);margin-top:2px}
|
|
2770
|
+
.tw-so-close{width:28px;height:28px;border:1px solid var(--border);background:var(--surface2);
|
|
2771
|
+
color:var(--muted);border-radius:7px;cursor:pointer;font-size:13px}
|
|
2772
|
+
.tw-so-close:hover{color:var(--text);border-color:#444c56}
|
|
2773
|
+
.tw-so-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px}
|
|
2774
|
+
.tw-so-stat{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px}
|
|
2775
|
+
.tw-so-stat-l{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px}
|
|
2776
|
+
.tw-so-stat-v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
2777
|
+
.tw-so-metarow{display:flex;justify-content:space-between;padding:11px 12px;background:var(--surface2);
|
|
2778
|
+
border:1px solid var(--border);border-radius:8px;margin-bottom:16px}
|
|
2779
|
+
.tw-so-metarow>div{display:flex;flex-direction:column;gap:3px}
|
|
2780
|
+
.tw-so-meta-l{font-size:10.5px;color:var(--muted)}
|
|
2781
|
+
.tw-so-meta-v{font-size:12.5px;font-weight:500;font-variant-numeric:tabular-nums}
|
|
2782
|
+
.tw-so-section-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
|
|
2783
|
+
.tw-so-calls{display:flex;flex-direction:column;gap:7px;margin-bottom:14px}
|
|
2784
|
+
.tw-so-call{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:9px 11px}
|
|
2785
|
+
.tw-so-call-top{display:flex;align-items:center;gap:9px;margin-bottom:5px}
|
|
2786
|
+
.tw-so-call-time{font-size:11px;color:var(--muted);flex:1}
|
|
2787
|
+
.tw-so-call-cost{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
2788
|
+
.tw-so-call-bot{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2789
|
+
.tw-so-viewall{width:100%;height:36px;border:1px solid var(--border);background:var(--surface2);
|
|
2790
|
+
color:var(--accent);border-radius:8px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:var(--sans);
|
|
2791
|
+
display:flex;align-items:center;justify-content:center;gap:6px}
|
|
2792
|
+
.tw-so-viewall:hover{border-color:var(--accent);background:rgba(88,166,255,.08)}
|
|
2793
|
+
.tw-cmdk-scrim{position:fixed;inset:0;background:rgba(1,4,9,.55);z-index:80;
|
|
2794
|
+
display:flex;justify-content:center;align-items:flex-start;padding-top:14vh;
|
|
2795
|
+
animation:tw-fade .15s ease}
|
|
2796
|
+
.tw-cmdk{width:560px;max-width:92vw;background:var(--surface2);border:1px solid var(--border);
|
|
2797
|
+
border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden}
|
|
2798
|
+
.tw-cmdk-input{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--border)}
|
|
2799
|
+
.tw-cmdk-input input{flex:1;background:transparent;border:0;outline:none;color:var(--text);
|
|
2800
|
+
font-size:14px;font-family:var(--sans)}
|
|
2801
|
+
.tw-cmdk-list{max-height:360px;overflow-y:auto;padding:6px}
|
|
2802
|
+
.tw-cmdk-item{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:7px;cursor:pointer}
|
|
2803
|
+
.tw-cmdk-item:hover{background:#22272e}
|
|
2804
|
+
.tw-cmdk-g{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);width:62px}
|
|
2805
|
+
.tw-cmdk-l{flex:1;font-size:13px}
|
|
2806
|
+
.tw-cmdk-empty{padding:18px;text-align:center;color:var(--muted);font-size:12.5px}
|
|
2807
|
+
.tw-skel-wrap{display:flex;flex-direction:column;gap:14px}
|
|
2808
|
+
.tw-skel{background:linear-gradient(90deg,var(--surface) 25%,#1b2128 50%,var(--surface) 75%);
|
|
2809
|
+
background-size:200% 100%;border:1px solid var(--border);border-radius:10px;
|
|
2810
|
+
animation:tw-shim 1.4s linear infinite}
|
|
2811
|
+
@keyframes tw-shim{to{background-position:-200% 0}}
|
|
2812
|
+
.tw-empty{display:flex;flex-direction:column;align-items:center;gap:12px;
|
|
2813
|
+
padding:80px 20px;text-align:center}
|
|
2814
|
+
.tw-empty-art{width:96px;height:96px;display:grid;place-items:center;
|
|
2815
|
+
background:var(--surface);border:1px solid var(--border);border-radius:20px}
|
|
2816
|
+
.tw-empty h2{font-size:18px}
|
|
2817
|
+
.tw-empty p{max-width:420px;color:var(--muted);font-size:13px;margin:0}
|
|
2818
|
+
.tw-empty-actions{display:flex;gap:10px;margin-top:6px}
|
|
2819
|
+
.tw-btn-primary{display:flex;align-items:center;gap:6px;height:34px;padding:0 14px;
|
|
2820
|
+
background:var(--accent);color:#0d1117;border:0;border-radius:8px;font-size:13px;
|
|
2821
|
+
font-weight:600;cursor:pointer;font-family:var(--sans)}
|
|
2822
|
+
.tw-btn-primary:hover{filter:brightness(1.08)}
|
|
2823
|
+
.tw-empty-snippet{margin-top:12px;font-family:var(--mono);font-size:12px;color:var(--muted);
|
|
2824
|
+
background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px}
|
|
2825
|
+
.tw-snip-c{color:#ff7b72}.tw-snip-s{color:var(--green)}
|
|
2826
|
+
.tw-so-call-lat{font-variant-numeric:tabular-nums}
|
|
2827
|
+
@media(prefers-reduced-motion:reduce){*{animation-duration:.001s!important}}
|
|
2148
2828
|
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
}
|
|
2829
|
+
/* tweaks panel */
|
|
2830
|
+
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
|
2831
|
+
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
|
2832
|
+
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
|
2833
|
+
background:rgba(250,249,247,.78);color:#29261b;
|
|
2834
|
+
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
|
2835
|
+
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
|
2836
|
+
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
|
2837
|
+
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
|
2838
|
+
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
|
2839
|
+
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
|
2840
|
+
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
|
2841
|
+
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
|
2842
|
+
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
|
2843
|
+
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
|
2844
|
+
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
|
2845
|
+
overflow-y:auto;overflow-x:hidden;min-height:0;
|
|
2846
|
+
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
|
2847
|
+
.twk-body::-webkit-scrollbar{width:8px}
|
|
2848
|
+
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
|
2849
|
+
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
|
2850
|
+
border:2px solid transparent;background-clip:content-box}
|
|
2851
|
+
.twk-row{display:flex;flex-direction:column;gap:5px}
|
|
2852
|
+
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
|
2853
|
+
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;color:rgba(41,38,27,.72)}
|
|
2854
|
+
.twk-lbl>span:first-child{font-weight:500}
|
|
2855
|
+
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
|
2856
|
+
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
|
2857
|
+
color:rgba(41,38,27,.45);padding:10px 0 0}
|
|
2858
|
+
.twk-sect:first-child{padding-top:0}
|
|
2859
|
+
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
|
2860
|
+
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
|
2861
|
+
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
|
2862
|
+
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
|
2863
|
+
select.twk-field{padding-right:22px}
|
|
2864
|
+
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
|
2865
|
+
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
|
2866
|
+
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
|
2867
|
+
width:14px;height:14px;border-radius:50%;background:#fff;
|
|
2868
|
+
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
|
2869
|
+
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
|
2870
|
+
background:rgba(0,0,0,.06);user-select:none}
|
|
2871
|
+
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
|
2872
|
+
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
|
2873
|
+
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
|
2874
|
+
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
|
2875
|
+
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
|
2876
|
+
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2}
|
|
2877
|
+
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
|
2878
|
+
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
|
2879
|
+
.twk-toggle[data-on="1"]{background:#34c759}
|
|
2880
|
+
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
|
2881
|
+
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
|
2882
|
+
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
|
2883
|
+
.twk-chips{display:flex;gap:6px}
|
|
2884
|
+
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
|
2885
|
+
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
|
2886
|
+
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);transition:transform .12s}
|
|
2887
|
+
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),0 2px 6px rgba(0,0,0,.15)}
|
|
2888
|
+
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;display:flex;flex-direction:column}
|
|
2889
|
+
.twk-chip>span>i{flex:1}
|
|
2890
|
+
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px}
|
|
2891
|
+
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
|
2892
|
+
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;background:transparent}
|
|
2893
|
+
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
|
2894
|
+
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
|
2895
|
+
</style>
|
|
2896
|
+
</head>
|
|
2897
|
+
<body>
|
|
2898
|
+
<div id="root"></div>
|
|
2899
|
+
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin="anonymous"></script>
|
|
2900
|
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin="anonymous"></script>
|
|
2901
|
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
|
|
2161
2902
|
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2903
|
+
<script type="text/babel">
|
|
2904
|
+
// tweaks-panel \u2014 without runtime style injection (CSS is in the main style block)
|
|
2905
|
+
function useTweaks(defaults) {
|
|
2906
|
+
const [values, setValues] = React.useState(defaults);
|
|
2907
|
+
const setTweak = React.useCallback((keyOrEdits, val) => {
|
|
2908
|
+
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
|
2909
|
+
? keyOrEdits : { [keyOrEdits]: val };
|
|
2910
|
+
setValues((prev) => ({ ...prev, ...edits }));
|
|
2911
|
+
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
|
2912
|
+
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
|
2913
|
+
}, []);
|
|
2914
|
+
return [values, setTweak];
|
|
2169
2915
|
}
|
|
2170
2916
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
.
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
.
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2917
|
+
function TweaksPanel({ title = 'Tweaks', children }) {
|
|
2918
|
+
const [open, setOpen] = React.useState(false);
|
|
2919
|
+
const dragRef = React.useRef(null);
|
|
2920
|
+
const offsetRef = React.useRef({ x: 16, y: 16 });
|
|
2921
|
+
const PAD = 16;
|
|
2922
|
+
const clampToViewport = React.useCallback(() => {
|
|
2923
|
+
const panel = dragRef.current; if (!panel) return;
|
|
2924
|
+
const w = panel.offsetWidth, h = panel.offsetHeight;
|
|
2925
|
+
offsetRef.current = {
|
|
2926
|
+
x: Math.min(Math.max(PAD, window.innerWidth - w - PAD), Math.max(PAD, offsetRef.current.x)),
|
|
2927
|
+
y: Math.min(Math.max(PAD, window.innerHeight - h - PAD), Math.max(PAD, offsetRef.current.y)),
|
|
2928
|
+
};
|
|
2929
|
+
panel.style.right = offsetRef.current.x + 'px';
|
|
2930
|
+
panel.style.bottom = offsetRef.current.y + 'px';
|
|
2931
|
+
}, []);
|
|
2932
|
+
React.useEffect(() => {
|
|
2933
|
+
if (!open) return;
|
|
2934
|
+
clampToViewport();
|
|
2935
|
+
const ro = new ResizeObserver(clampToViewport);
|
|
2936
|
+
ro.observe(document.documentElement);
|
|
2937
|
+
return () => ro.disconnect();
|
|
2938
|
+
}, [open, clampToViewport]);
|
|
2939
|
+
React.useEffect(() => {
|
|
2940
|
+
const onMsg = (e) => {
|
|
2941
|
+
const t = e && e.data && e.data.type;
|
|
2942
|
+
if (t === '__activate_edit_mode') setOpen(true);
|
|
2943
|
+
else if (t === '__deactivate_edit_mode') setOpen(false);
|
|
2944
|
+
};
|
|
2945
|
+
window.addEventListener('message', onMsg);
|
|
2946
|
+
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
|
2947
|
+
return () => window.removeEventListener('message', onMsg);
|
|
2948
|
+
}, []);
|
|
2949
|
+
const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); };
|
|
2950
|
+
const onDragStart = (e) => {
|
|
2951
|
+
const panel = dragRef.current; if (!panel) return;
|
|
2952
|
+
const r = panel.getBoundingClientRect();
|
|
2953
|
+
const sx = e.clientX, sy = e.clientY;
|
|
2954
|
+
const startRight = window.innerWidth - r.right, startBottom = window.innerHeight - r.bottom;
|
|
2955
|
+
const move = (ev) => { offsetRef.current = { x: startRight - (ev.clientX - sx), y: startBottom - (ev.clientY - sy) }; clampToViewport(); };
|
|
2956
|
+
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
|
2957
|
+
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
|
|
2958
|
+
};
|
|
2959
|
+
if (!open) return null;
|
|
2960
|
+
return (
|
|
2961
|
+
<div ref={dragRef} className="twk-panel" style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
|
2962
|
+
<div className="twk-hd" onMouseDown={onDragStart}>
|
|
2963
|
+
<b>{title}</b>
|
|
2964
|
+
<button className="twk-x" onMouseDown={(e) => e.stopPropagation()} onClick={dismiss}>✕</button>
|
|
2965
|
+
</div>
|
|
2966
|
+
<div className="twk-body">{children}</div>
|
|
2967
|
+
</div>
|
|
2968
|
+
);
|
|
2199
2969
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
border-radius: var(--r);
|
|
2209
|
-
padding: 4px;
|
|
2210
|
-
width: fit-content;
|
|
2211
|
-
}
|
|
2212
|
-
.tab {
|
|
2213
|
-
padding: 6px 14px;
|
|
2214
|
-
border-radius: calc(var(--r) - 2px);
|
|
2215
|
-
border: none;
|
|
2216
|
-
background: transparent;
|
|
2217
|
-
color: var(--muted);
|
|
2218
|
-
font-size: 13px;
|
|
2219
|
-
font-weight: 500;
|
|
2220
|
-
cursor: pointer;
|
|
2221
|
-
transition: background 0.15s, color 0.15s;
|
|
2222
|
-
}
|
|
2223
|
-
.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
|
2224
|
-
.tab.active { background: var(--accent); color: #fff; }
|
|
2225
|
-
|
|
2226
|
-
/* \u2500\u2500 Overview cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2227
|
-
.cards {
|
|
2228
|
-
display: grid;
|
|
2229
|
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2230
|
-
gap: 12px;
|
|
2231
|
-
margin-bottom: 24px;
|
|
2232
|
-
}
|
|
2233
|
-
.card {
|
|
2234
|
-
background: var(--surface);
|
|
2235
|
-
border: 1px solid var(--border);
|
|
2236
|
-
border-radius: var(--r);
|
|
2237
|
-
padding: 16px;
|
|
2238
|
-
}
|
|
2239
|
-
.card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 6px; }
|
|
2240
|
-
.card-value { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
2241
|
-
.card-sub { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
|
2242
|
-
|
|
2243
|
-
/* \u2500\u2500 Chart section \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2244
|
-
.charts {
|
|
2245
|
-
display: grid;
|
|
2246
|
-
grid-template-columns: 2fr 1fr;
|
|
2247
|
-
gap: 16px;
|
|
2248
|
-
margin-bottom: 24px;
|
|
2249
|
-
}
|
|
2250
|
-
@media (max-width: 768px) { .charts { grid-template-columns: 1fr; } }
|
|
2251
|
-
|
|
2252
|
-
.panel {
|
|
2253
|
-
background: var(--surface);
|
|
2254
|
-
border: 1px solid var(--border);
|
|
2255
|
-
border-radius: var(--r);
|
|
2256
|
-
padding: 16px;
|
|
2257
|
-
}
|
|
2258
|
-
.panel-title {
|
|
2259
|
-
font-size: 12px;
|
|
2260
|
-
font-weight: 600;
|
|
2261
|
-
text-transform: uppercase;
|
|
2262
|
-
letter-spacing: 0.5px;
|
|
2263
|
-
color: var(--muted);
|
|
2264
|
-
margin-bottom: 14px;
|
|
2265
|
-
}
|
|
2266
|
-
.chart-wrap { position: relative; height: 220px; }
|
|
2267
|
-
|
|
2268
|
-
/* \u2500\u2500 Breakdown tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2269
|
-
.section { margin-bottom: 24px; }
|
|
2270
|
-
.section-title {
|
|
2271
|
-
font-size: 12px;
|
|
2272
|
-
font-weight: 600;
|
|
2273
|
-
text-transform: uppercase;
|
|
2274
|
-
letter-spacing: 0.5px;
|
|
2275
|
-
color: var(--muted);
|
|
2276
|
-
margin-bottom: 10px;
|
|
2970
|
+
function TweakSection({ label }) { return <div className="twk-sect">{label}</div>; }
|
|
2971
|
+
function TweakRow({ label, value, children, inline }) {
|
|
2972
|
+
return (
|
|
2973
|
+
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
|
2974
|
+
<div className="twk-lbl"><span>{label}</span>{value != null && <span className="twk-val">{value}</span>}</div>
|
|
2975
|
+
{children}
|
|
2976
|
+
</div>
|
|
2977
|
+
);
|
|
2277
2978
|
}
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
border-radius: var(--r);
|
|
2285
|
-
overflow: hidden;
|
|
2286
|
-
font-size: 13px;
|
|
2287
|
-
}
|
|
2288
|
-
thead { background: rgba(255,255,255,0.03); }
|
|
2289
|
-
th {
|
|
2290
|
-
padding: 10px 14px;
|
|
2291
|
-
text-align: left;
|
|
2292
|
-
font-weight: 600;
|
|
2293
|
-
color: var(--muted);
|
|
2294
|
-
font-size: 11px;
|
|
2295
|
-
text-transform: uppercase;
|
|
2296
|
-
letter-spacing: 0.4px;
|
|
2297
|
-
border-bottom: 1px solid var(--border);
|
|
2298
|
-
}
|
|
2299
|
-
th.num, td.num { text-align: right; }
|
|
2300
|
-
td {
|
|
2301
|
-
padding: 10px 14px;
|
|
2302
|
-
border-bottom: 1px solid rgba(48,54,61,0.5);
|
|
2303
|
-
font-variant-numeric: tabular-nums;
|
|
2304
|
-
color: var(--text);
|
|
2305
|
-
}
|
|
2306
|
-
tbody tr:last-child td { border-bottom: none; }
|
|
2307
|
-
tbody tr:hover td { background: rgba(255,255,255,0.02); }
|
|
2308
|
-
|
|
2309
|
-
.bar-wrap { width: 80px; height: 6px; background: var(--border); border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 8px; }
|
|
2310
|
-
.bar-fill { height: 100%; border-radius: 3px; background: var(--accent); }
|
|
2311
|
-
|
|
2312
|
-
/* \u2500\u2500 Forecast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2313
|
-
.forecast-grid {
|
|
2314
|
-
display: grid;
|
|
2315
|
-
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
2316
|
-
gap: 12px;
|
|
2979
|
+
function TweakSlider({ label, value, min=0, max=100, step=1, unit='', onChange }) {
|
|
2980
|
+
return (
|
|
2981
|
+
<TweakRow label={label} value={value + unit}>
|
|
2982
|
+
<input type="range" className="twk-slider" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
2983
|
+
</TweakRow>
|
|
2984
|
+
);
|
|
2317
2985
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2986
|
+
function TweakToggle({ label, value, onChange }) {
|
|
2987
|
+
return (
|
|
2988
|
+
<div className="twk-row twk-row-h">
|
|
2989
|
+
<div className="twk-lbl"><span>{label}</span></div>
|
|
2990
|
+
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'} onClick={() => onChange(!value)}><i /></button>
|
|
2991
|
+
</div>
|
|
2992
|
+
);
|
|
2325
2993
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
font-size: 12px;
|
|
2335
|
-
font-weight: 600;
|
|
2336
|
-
text-transform: uppercase;
|
|
2337
|
-
letter-spacing: 0.5px;
|
|
2338
|
-
color: var(--muted);
|
|
2339
|
-
margin-bottom: 10px;
|
|
2340
|
-
user-select: none;
|
|
2341
|
-
}
|
|
2342
|
-
details summary::before { content: '\u25B6'; font-size: 10px; transition: transform 0.15s; }
|
|
2343
|
-
details[open] summary::before { transform: rotate(90deg); }
|
|
2344
|
-
|
|
2345
|
-
/* \u2500\u2500 Last updated \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2346
|
-
.footer {
|
|
2347
|
-
font-size: 11px;
|
|
2348
|
-
color: var(--muted);
|
|
2349
|
-
text-align: center;
|
|
2350
|
-
padding: 16px 0 0;
|
|
2994
|
+
function TweakSelect({ label, value, options, onChange }) {
|
|
2995
|
+
return (
|
|
2996
|
+
<TweakRow label={label}>
|
|
2997
|
+
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
|
2998
|
+
{options.map((o) => { const v = typeof o === 'object' ? o.value : o; const l = typeof o === 'object' ? o.label : o; return <option key={v} value={v}>{l}</option>; })}
|
|
2999
|
+
</select>
|
|
3000
|
+
</TweakRow>
|
|
3001
|
+
);
|
|
2351
3002
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
3003
|
+
function TweakRadio({ label, value, options, onChange }) {
|
|
3004
|
+
const trackRef = React.useRef(null);
|
|
3005
|
+
const [dragging, setDragging] = React.useState(false);
|
|
3006
|
+
const valueRef = React.useRef(value); valueRef.current = value;
|
|
3007
|
+
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
|
3008
|
+
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
|
3009
|
+
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] || 0);
|
|
3010
|
+
if (!fitsAsSegments) {
|
|
3011
|
+
const resolve = (s) => { const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s); return m === undefined ? s : typeof m === 'object' ? m.value : m; };
|
|
3012
|
+
return <TweakSelect label={label} value={value} options={options} onChange={(s) => onChange(resolve(s))} />;
|
|
3013
|
+
}
|
|
3014
|
+
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
|
3015
|
+
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
|
3016
|
+
const n = opts.length;
|
|
3017
|
+
const segAt = (clientX) => {
|
|
3018
|
+
const r = trackRef.current.getBoundingClientRect();
|
|
3019
|
+
const i = Math.floor(((clientX - r.left - 2) / (r.width - 4)) * n);
|
|
3020
|
+
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
|
3021
|
+
};
|
|
3022
|
+
const onPointerDown = (e) => {
|
|
3023
|
+
setDragging(true);
|
|
3024
|
+
const v0 = segAt(e.clientX); if (v0 !== valueRef.current) onChange(v0);
|
|
3025
|
+
const move = (ev) => { if (!trackRef.current) return; const v = segAt(ev.clientX); if (v !== valueRef.current) onChange(v); };
|
|
3026
|
+
const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
3027
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
3028
|
+
};
|
|
3029
|
+
return (
|
|
3030
|
+
<TweakRow label={label}>
|
|
3031
|
+
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown} className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
|
3032
|
+
<div className="twk-seg-thumb" style={{ left: 'calc(2px + ' + idx + ' * (100% - 4px) / ' + n + ')', width: 'calc((100% - 4px) / ' + n + ')' }} />
|
|
3033
|
+
{opts.map((o) => <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>{o.label}</button>)}
|
|
3034
|
+
</div>
|
|
3035
|
+
</TweakRow>
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
function __twkIsLight(hex) {
|
|
3039
|
+
const h = String(hex).replace('#', '');
|
|
3040
|
+
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
|
3041
|
+
const n = parseInt(x.slice(0, 6), 16);
|
|
3042
|
+
if (Number.isNaN(n)) return true;
|
|
3043
|
+
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
|
3044
|
+
return r * 299 + g * 587 + b * 114 > 148000;
|
|
3045
|
+
}
|
|
3046
|
+
const __TwkCheck = ({ light }) => (
|
|
3047
|
+
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
3048
|
+
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
|
3049
|
+
</svg>
|
|
3050
|
+
);
|
|
3051
|
+
function TweakColor({ label, value, options, onChange }) {
|
|
3052
|
+
if (!options || !options.length) {
|
|
3053
|
+
return (
|
|
3054
|
+
<div className="twk-row twk-row-h">
|
|
3055
|
+
<div className="twk-lbl"><span>{label}</span></div>
|
|
3056
|
+
<input type="color" className="twk-swatch" value={value} onChange={(e) => onChange(e.target.value)} />
|
|
3057
|
+
</div>
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
|
3061
|
+
const cur = key(value);
|
|
3062
|
+
return (
|
|
3063
|
+
<TweakRow label={label}>
|
|
3064
|
+
<div className="twk-chips" role="radiogroup">
|
|
3065
|
+
{options.map((o, i) => {
|
|
3066
|
+
const colors = Array.isArray(o) ? o : [o];
|
|
3067
|
+
const [hero, ...rest] = colors;
|
|
3068
|
+
const sup = rest.slice(0, 4);
|
|
3069
|
+
const on = key(o) === cur;
|
|
3070
|
+
return (
|
|
3071
|
+
<button key={i} type="button" className="twk-chip" role="radio" data-on={on ? '1' : '0'} style={{ background: hero }} onClick={() => onChange(o)}>
|
|
3072
|
+
{sup.length > 0 && <span>{sup.map((c, j) => <i key={j} style={{ background: c }} />)}</span>}
|
|
3073
|
+
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
|
3074
|
+
</button>
|
|
3075
|
+
);
|
|
3076
|
+
})}
|
|
3077
|
+
</div>
|
|
3078
|
+
</TweakRow>
|
|
3079
|
+
);
|
|
3080
|
+
}
|
|
3081
|
+
Object.assign(window, { useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, TweakToggle, TweakRadio, TweakSelect, TweakColor });
|
|
3082
|
+
</script>
|
|
2356
3083
|
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
3084
|
+
<script type="text/babel">
|
|
3085
|
+
// tw-charts.jsx
|
|
3086
|
+
const { useRef, useState, useEffect, useLayoutEffect } = React;
|
|
3087
|
+
function useMeasure() {
|
|
3088
|
+
const ref = useRef(null);
|
|
3089
|
+
const [w, setW] = useState(0);
|
|
3090
|
+
useLayoutEffect(() => {
|
|
3091
|
+
if (!ref.current) return;
|
|
3092
|
+
const ro = new ResizeObserver((es) => setW(es[0].contentRect.width));
|
|
3093
|
+
ro.observe(ref.current);
|
|
3094
|
+
setW(ref.current.clientWidth);
|
|
3095
|
+
return () => ro.disconnect();
|
|
3096
|
+
}, []);
|
|
3097
|
+
return [ref, w];
|
|
3098
|
+
}
|
|
3099
|
+
function useCountUp(target, { duration = 700, enabled = true, decimals = 0 } = {}) {
|
|
3100
|
+
const [val, setVal] = useState(enabled ? 0 : target);
|
|
3101
|
+
const fromRef = useRef(enabled ? 0 : target);
|
|
3102
|
+
useEffect(() => {
|
|
3103
|
+
if (!enabled) { setVal(target); return; }
|
|
3104
|
+
const from = fromRef.current, start = performance.now();
|
|
3105
|
+
let raf;
|
|
3106
|
+
const tick = (t) => {
|
|
3107
|
+
const p = Math.min(1, (t - start) / duration);
|
|
3108
|
+
const e = 1 - Math.pow(1 - p, 3);
|
|
3109
|
+
setVal(from + (target - from) * e);
|
|
3110
|
+
if (p < 1) raf = requestAnimationFrame(tick);
|
|
3111
|
+
else fromRef.current = target;
|
|
3112
|
+
};
|
|
3113
|
+
raf = requestAnimationFrame(tick);
|
|
3114
|
+
return () => cancelAnimationFrame(raf);
|
|
3115
|
+
}, [target, enabled, duration]);
|
|
3116
|
+
return val;
|
|
3117
|
+
}
|
|
3118
|
+
function LineChart({ current, previous, n, color = '#58a6ff', compare = false, animate = true, fmt }) {
|
|
3119
|
+
const [ref, w] = useMeasure();
|
|
3120
|
+
const [hover, setHover] = useState(null);
|
|
3121
|
+
const H = 248, padT = 16, padB = 26, padL = 8, padR = 8;
|
|
3122
|
+
const innerW = Math.max(1, w - padL - padR), innerH = H - padT - padB;
|
|
3123
|
+
const series = compare && previous ? [...current, ...previous] : current;
|
|
3124
|
+
const max = Math.max(...series, 0.0001) * 1.18;
|
|
3125
|
+
const X = (i) => padL + (i / Math.max(n - 1, 1)) * innerW;
|
|
3126
|
+
const Y = (v) => padT + innerH - (v / max) * innerH;
|
|
3127
|
+
const path = (arr) => arr.map((v, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(v).toFixed(1)).join(' ');
|
|
3128
|
+
const area = (arr) => path(arr) + ' L' + X(n - 1).toFixed(1) + ' ' + (padT + innerH).toFixed(1) + ' L' + padL.toFixed(1) + ' ' + (padT + innerH).toFixed(1) + ' Z';
|
|
3129
|
+
const gid = 'lg' + color.replace('#', '');
|
|
3130
|
+
const onMove = (e) => {
|
|
3131
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
3132
|
+
const x = e.clientX - r.left - padL;
|
|
3133
|
+
const i = Math.max(0, Math.min(n - 1, Math.round((x / innerW) * (n - 1))));
|
|
3134
|
+
setHover(i);
|
|
3135
|
+
};
|
|
3136
|
+
return (
|
|
3137
|
+
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
|
3138
|
+
{w > 0 && (
|
|
3139
|
+
<svg width={w} height={H} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{ display: 'block', cursor: 'crosshair' }}>
|
|
3140
|
+
<defs>
|
|
3141
|
+
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
3142
|
+
<stop offset="0%" stopColor={color} stopOpacity="0.22" />
|
|
3143
|
+
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
3144
|
+
</linearGradient>
|
|
3145
|
+
</defs>
|
|
3146
|
+
{[0.25, 0.5, 0.75, 1].map((g, i) => (
|
|
3147
|
+
<line key={i} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke="#21262d" strokeWidth="1" />
|
|
3148
|
+
))}
|
|
3149
|
+
{current.length > 1 && <path d={area(current)} fill={'url(#' + gid + ')'} className={animate ? 'tw-draw-area' : ''} />}
|
|
3150
|
+
{compare && previous && previous.length > 1 && (
|
|
3151
|
+
<path d={path(previous)} fill="none" stroke="#6e7681" strokeWidth="1.6" strokeDasharray="5 4" opacity="0.85" />
|
|
3152
|
+
)}
|
|
3153
|
+
{current.length > 1 && <path d={path(current)} fill="none" stroke={color} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" className={animate ? 'tw-draw-line' : ''} />}
|
|
3154
|
+
{hover != null && (
|
|
3155
|
+
<g>
|
|
3156
|
+
<line x1={X(hover)} x2={X(hover)} y1={padT} y2={padT + innerH} stroke="#484f58" strokeWidth="1" strokeDasharray="3 3" />
|
|
3157
|
+
{compare && previous && previous[hover] != null && <circle cx={X(hover)} cy={Y(previous[hover])} r="3.5" fill="#21262d" stroke="#6e7681" strokeWidth="1.6" />}
|
|
3158
|
+
<circle cx={X(hover)} cy={Y(current[hover])} r="4.5" fill="#0d1117" stroke={color} strokeWidth="2.2" />
|
|
3159
|
+
</g>
|
|
3160
|
+
)}
|
|
3161
|
+
</svg>
|
|
3162
|
+
)}
|
|
3163
|
+
{hover != null && w > 0 && (
|
|
3164
|
+
<div style={{ position: 'absolute', top: 6, pointerEvents: 'none', left: Math.min(Math.max(X(hover) - 70, 4), w - 144), width: 140, background: '#1c2128', border: '1px solid #30363d', borderRadius: 6, padding: '6px 8px', fontSize: 11, boxShadow: '0 6px 20px rgba(0,0,0,.5)' }}>
|
|
3165
|
+
<div style={{ color: '#7d8590', marginBottom: 3 }}>point {hover + 1}/{n}</div>
|
|
3166
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#e6edf3', fontVariantNumeric: 'tabular-nums' }}>
|
|
3167
|
+
<span style={{ color }}>● current</span><b>{fmt ? fmt(current[hover]) : current[hover].toFixed(4)}</b>
|
|
3168
|
+
</div>
|
|
3169
|
+
{compare && previous && previous[hover] != null && (
|
|
3170
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#8b949e', fontVariantNumeric: 'tabular-nums', marginTop: 2 }}>
|
|
3171
|
+
<span>○ previous</span><span>{fmt ? fmt(previous[hover]) : previous[hover].toFixed(4)}</span>
|
|
3172
|
+
</div>
|
|
3173
|
+
)}
|
|
3174
|
+
</div>
|
|
3175
|
+
)}
|
|
2362
3176
|
</div>
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
<
|
|
2371
|
-
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
function Doughnut({ data, total, fmt, active, onHover }) {
|
|
3180
|
+
const size = 188, stroke = 26, r = (size - stroke) / 2, c = 2 * Math.PI * r;
|
|
3181
|
+
let acc = 0;
|
|
3182
|
+
const safeTotal = total || 0.0001;
|
|
3183
|
+
return (
|
|
3184
|
+
<svg width={size} height={size} viewBox={'0 0 ' + size + ' ' + size}>
|
|
3185
|
+
<g transform={'rotate(-90 ' + (size / 2) + ' ' + (size / 2) + ')'}>
|
|
3186
|
+
{data.map((d) => {
|
|
3187
|
+
const frac = d.cost / safeTotal;
|
|
3188
|
+
const dash = frac * c;
|
|
3189
|
+
const seg = (
|
|
3190
|
+
<circle key={d.id} cx={size / 2} cy={size / 2} r={r} fill="none"
|
|
3191
|
+
stroke={d.color} strokeWidth={active === d.id ? stroke + 5 : stroke}
|
|
3192
|
+
strokeDasharray={dash + ' ' + (c - dash)} strokeDashoffset={-acc}
|
|
3193
|
+
opacity={active && active !== d.id ? 0.32 : 1}
|
|
3194
|
+
style={{ transition: 'opacity .15s, stroke-width .15s', cursor: 'pointer' }}
|
|
3195
|
+
onMouseEnter={() => onHover && onHover(d.id)} onMouseLeave={() => onHover && onHover(null)} />
|
|
3196
|
+
);
|
|
3197
|
+
acc += dash;
|
|
3198
|
+
return seg;
|
|
3199
|
+
})}
|
|
3200
|
+
</g>
|
|
3201
|
+
<text x="50%" y="46%" textAnchor="middle" fill="#7d8590" fontSize="11" style={{ textTransform: 'uppercase', letterSpacing: '.05em' }}>
|
|
3202
|
+
{active ? ((data.find((d) => d.id === active) || {}).id || 'total') : 'total'}
|
|
3203
|
+
</text>
|
|
3204
|
+
<text x="50%" y="58%" textAnchor="middle" fill="#e6edf3" fontSize="20" fontWeight="600" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
3205
|
+
{active ? fmt(((data.find((d) => d.id === active) || {}).cost) || 0) : fmt(total)}
|
|
3206
|
+
</text>
|
|
3207
|
+
</svg>
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
function Sparkline({ data, color = '#58a6ff', w = 92, h = 30 }) {
|
|
3211
|
+
if (!data || data.length < 2) return null;
|
|
3212
|
+
const max = Math.max(...data, 0.0001), min = Math.min(...data);
|
|
3213
|
+
const X = (i) => (i / (data.length - 1)) * w;
|
|
3214
|
+
const Y = (v) => h - 2 - ((v - min) / (max - min || 1)) * (h - 4);
|
|
3215
|
+
const d = data.map((v, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(v).toFixed(1)).join(' ');
|
|
3216
|
+
return (
|
|
3217
|
+
<svg width={w} height={h} style={{ display: 'block' }}>
|
|
3218
|
+
<path d={d + ' L' + w + ' ' + h + ' L0 ' + h + ' Z'} fill={color} opacity="0.12" />
|
|
3219
|
+
<path d={d} fill="none" stroke={color} strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
|
|
3220
|
+
</svg>
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
function ShareBar({ frac, color }) {
|
|
3224
|
+
return (
|
|
3225
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3226
|
+
<div style={{ flex: 1, height: 6, background: '#21262d', borderRadius: 3, overflow: 'hidden', minWidth: 40 }}>
|
|
3227
|
+
<div style={{ width: (frac * 100).toFixed(1) + '%', height: '100%', background: color, borderRadius: 3, transition: 'width .4s' }} />
|
|
3228
|
+
</div>
|
|
3229
|
+
<span style={{ color: '#7d8590', fontSize: 11, width: 34, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{(frac * 100).toFixed(1)}%</span>
|
|
3230
|
+
</div>
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
Object.assign(window, { useMeasure, useCountUp, LineChart, Doughnut, Sparkline, ShareBar });
|
|
3234
|
+
</script>
|
|
2372
3235
|
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
3236
|
+
<script type="text/babel">
|
|
3237
|
+
// tw-cards.jsx
|
|
3238
|
+
const { useState: useStateC } = React;
|
|
3239
|
+
const Ico = {
|
|
3240
|
+
chevron: (p) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M3 4.5 6 7.5 9 4.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>,
|
|
3241
|
+
search: (p) => <svg width="14" height="14" viewBox="0 0 14 14" {...p}><circle cx="6" cy="6" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M9.2 9.2 12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>,
|
|
3242
|
+
download: (p) => <svg width="13" height="13" viewBox="0 0 14 14" {...p}><path d="M7 1.5v7m0 0 2.6-2.6M7 8.5 4.4 5.9M2 11.5h10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>,
|
|
3243
|
+
bolt: (p) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M7 1 2.5 7H5l-.5 4L9 5H6.5z" fill="currentColor" /></svg>,
|
|
3244
|
+
arrow: (p) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M2.5 6h7m0 0L7 3.5M9.5 6 7 8.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>,
|
|
3245
|
+
warn: (p) => <svg width="13" height="13" viewBox="0 0 14 14" {...p}><path d="M7 1.5 13 12H1z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /><path d="M7 5.5v3M7 10.2v.1" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>,
|
|
3246
|
+
check: (p) => <svg width="13" height="13" viewBox="0 0 14 14" {...p}><path d="M2.5 7.5 5.5 10.5 11.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>,
|
|
3247
|
+
};
|
|
3248
|
+
function Header({ t, onOpenPalette }) {
|
|
3249
|
+
const [wsOpen, setWsOpen] = useStateC(false);
|
|
3250
|
+
const nav = ['Overview', 'Sessions', 'Users', 'Features', 'Settings'];
|
|
3251
|
+
return (
|
|
3252
|
+
<header className="tw-header">
|
|
3253
|
+
<div className="tw-hgroup">
|
|
3254
|
+
<div className="tw-logo">token<span>watch</span></div>
|
|
3255
|
+
<button className="tw-ws" onClick={() => setWsOpen((v) => !v)}>
|
|
3256
|
+
<span className="tw-ws-dot" />
|
|
3257
|
+
<span>tokenwatch</span>
|
|
3258
|
+
<span className="tw-ws-env">local</span>
|
|
3259
|
+
<Ico.chevron style={{ color: '#7d8590' }} />
|
|
3260
|
+
</button>
|
|
3261
|
+
<nav className="tw-nav">
|
|
3262
|
+
{nav.map((n, i) => <a key={n} className={i === 0 ? 'on' : ''} href="#" onClick={(e) => e.preventDefault()}>{n}</a>)}
|
|
3263
|
+
</nav>
|
|
3264
|
+
</div>
|
|
3265
|
+
<div className="tw-hgroup">
|
|
3266
|
+
{t.commandPalette && (
|
|
3267
|
+
<button className="tw-search" onClick={onOpenPalette}>
|
|
3268
|
+
<Ico.search style={{ color: '#7d8590' }} />
|
|
3269
|
+
<span>Search</span>
|
|
3270
|
+
<kbd>⌘K</kbd>
|
|
3271
|
+
</button>
|
|
3272
|
+
)}
|
|
3273
|
+
<button className="tw-btn-2"><Ico.download /> Export CSV</button>
|
|
3274
|
+
</div>
|
|
3275
|
+
</header>
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
function BudgetBar({ t }) {
|
|
3279
|
+
const { BUDGET, fmtMoney } = window.TW;
|
|
3280
|
+
const { used, limit, daysLeft, cycleDays } = BUDGET;
|
|
3281
|
+
const pct = Math.min(used / Math.max(limit, 0.0001), 1);
|
|
3282
|
+
const elapsed = cycleDays - daysLeft;
|
|
3283
|
+
const barColor = pct < 0.5 ? '#3fb950' : pct < 0.8 ? '#d29922' : '#f85149';
|
|
3284
|
+
const projectedDaily = used / Math.max(elapsed, 1);
|
|
3285
|
+
const projected = used + projectedDaily * daysLeft;
|
|
3286
|
+
const projPct = Math.min(projected / Math.max(limit, 0.0001), 1);
|
|
3287
|
+
return (
|
|
3288
|
+
<div className="tw-budget">
|
|
3289
|
+
<div className="tw-budget-top">
|
|
3290
|
+
<div className="tw-budget-label">
|
|
3291
|
+
<span className="tw-bud-strong">Monthly budget</span>
|
|
3292
|
+
<span className="tw-bud-nums"><b>{fmtMoney(used)}</b> used of {fmtMoney(limit)}</span>
|
|
3293
|
+
</div>
|
|
3294
|
+
{t.budgetAlerts && (
|
|
3295
|
+
<div className="tw-bud-alert ok">
|
|
3296
|
+
<Ico.check style={{ color: '#3fb950' }} />
|
|
3297
|
+
Projected <b>{fmtMoney(projected)}</b> by cycle end
|
|
3298
|
+
</div>
|
|
3299
|
+
)}
|
|
3300
|
+
<div className="tw-budget-days">{daysLeft} days left · day {elapsed}/{cycleDays}</div>
|
|
3301
|
+
</div>
|
|
3302
|
+
<div className="tw-bar">
|
|
3303
|
+
<div className="tw-bar-fill" style={{ width: (pct * 100) + '%', background: barColor }} />
|
|
3304
|
+
{t.budgetAlerts && <div className="tw-bar-proj" style={{ left: (pct * 100) + '%', width: ((projPct - pct) * 100) + '%' }} />}
|
|
3305
|
+
{t.budgetAlerts && <div className="tw-bar-marker" style={{ left: (projPct * 100) + '%' }} data-tip={'proj ' + fmtMoney(projected)} />}
|
|
3306
|
+
</div>
|
|
2378
3307
|
</div>
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
function KpiCard({ label, value, sub, delta, deltaColor, spark, sparkColor, t }) {
|
|
3311
|
+
return (
|
|
3312
|
+
<div className="tw-kpi">
|
|
3313
|
+
<div className="tw-kpi-label">{label}</div>
|
|
3314
|
+
<div className="tw-kpi-main">
|
|
3315
|
+
<div className="tw-kpi-value">{value}</div>
|
|
3316
|
+
{t.kpiSparklines && spark && spark.length >= 2 && <Sparkline data={spark} color={sparkColor} w={84} h={28} />}
|
|
3317
|
+
</div>
|
|
3318
|
+
<div className="tw-kpi-foot">
|
|
3319
|
+
{delta && <span className="tw-delta" style={{ color: t.smartHighlight ? deltaColor : '#7d8590' }}>{delta}</span>}
|
|
3320
|
+
{sub && <span className="tw-kpi-sub">{sub}</span>}
|
|
3321
|
+
</div>
|
|
2382
3322
|
</div>
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
3323
|
+
);
|
|
3324
|
+
}
|
|
3325
|
+
function KpiRow({ kpis, range, t }) {
|
|
3326
|
+
const { fmtUSD, fmtInt, seriesForRange } = window.TW;
|
|
3327
|
+
const s = seriesForRange(range).current;
|
|
3328
|
+
const callsSeries = s.map((v, i) => 6 + Math.abs(Math.sin(i * 1.7)) * 14);
|
|
3329
|
+
return (
|
|
3330
|
+
<div className="tw-kpis">
|
|
3331
|
+
<KpiCard t={t} label="Total cost" value={fmtUSD(kpis.cost)} delta="↑ 12% vs last week" deltaColor="#f85149" spark={s} sparkColor="#58a6ff" />
|
|
3332
|
+
<KpiCard t={t} label="Input tokens" value={fmtInt(kpis.inTok)} sub="tokens in" spark={s} sparkColor="#3fb950" />
|
|
3333
|
+
<KpiCard t={t} label="Output tokens" value={fmtInt(kpis.outTok)} sub="tokens out" spark={s.map((v) => v * 0.9)} sparkColor="#bc8cff" />
|
|
3334
|
+
<KpiCard t={t} label="Total calls" value={fmtInt(kpis.calls)} delta="↓ 5% vs last week" deltaColor="#3fb950" spark={callsSeries} sparkColor="#56d4dd" />
|
|
3335
|
+
<KpiCard t={t} label="Burn rate" value={fmtUSD(kpis.burnHr, 4) + '/hr'} sub={'proj ' + fmtUSD(kpis.burnHr * 24, 2) + '/day'} spark={s.map((v) => v * 1.05)} sparkColor="#e3b341" />
|
|
2386
3336
|
</div>
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
function TimeFilter({ range, setRange, t, setTweak }) {
|
|
3340
|
+
const ranges = ['1h', '24h', '7d', '30d', 'All'];
|
|
3341
|
+
return (
|
|
3342
|
+
<div className="tw-timefilter">
|
|
3343
|
+
<div className="tw-tabs">
|
|
3344
|
+
{ranges.map((r) => (
|
|
3345
|
+
<button key={r} className={'tw-tab' + (r === range ? ' on' : '')} onClick={() => setRange(r)}>{r}</button>
|
|
3346
|
+
))}
|
|
3347
|
+
</div>
|
|
3348
|
+
<label className="tw-compare">
|
|
3349
|
+
<button className="tw-toggle-sm" data-on={t.compareMode ? '1' : '0'} onClick={() => setTweak('compareMode', !t.compareMode)}><i /></button>
|
|
3350
|
+
Compare to previous period
|
|
3351
|
+
</label>
|
|
2390
3352
|
</div>
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3355
|
+
function ForecastCard({ label, value, sub, accent, flag }) {
|
|
3356
|
+
return (
|
|
3357
|
+
<div className={'tw-fc' + (flag ? ' flag' : '')}>
|
|
3358
|
+
<div className="tw-fc-label">{label}</div>
|
|
3359
|
+
<div className="tw-fc-value" style={accent ? { color: accent } : null}>{value}</div>
|
|
3360
|
+
{sub && <div className="tw-fc-sub">{sub}</div>}
|
|
2395
3361
|
</div>
|
|
2396
|
-
|
|
3362
|
+
);
|
|
3363
|
+
}
|
|
3364
|
+
function ForecastSection({ t }) {
|
|
3365
|
+
const { fmtUSD, fmtMoney, BUDGET } = window.TW;
|
|
3366
|
+
const daily = BUDGET.daily != null ? BUDGET.daily : 0.8473;
|
|
3367
|
+
const burnHr = daily / 24;
|
|
3368
|
+
const remaining = daily * BUDGET.daysLeft;
|
|
3369
|
+
const projCycle = BUDGET.used + remaining;
|
|
3370
|
+
const g = t.forecastScenario / 100;
|
|
3371
|
+
const scenarioCycle = BUDGET.used + remaining * (1 + g);
|
|
3372
|
+
const over = scenarioCycle > BUDGET.limit;
|
|
3373
|
+
return (
|
|
3374
|
+
<section className="tw-section">
|
|
3375
|
+
<div className="tw-sec-head"><h3>Cost forecast</h3><span className="tw-sec-sub">based on current run-rate</span></div>
|
|
3376
|
+
<div className="tw-forecast">
|
|
3377
|
+
<ForecastCard label="Projected daily" value={fmtUSD(daily, 2)} sub="next 24h at this rate" />
|
|
3378
|
+
<ForecastCard label="Projected this cycle" value={fmtMoney(projCycle)} sub={'of ' + fmtMoney(BUDGET.limit) + ' budget'} />
|
|
3379
|
+
<ForecastCard label="Burn rate" value={fmtUSD(burnHr, 4)} sub="per hour" accent="#e3b341" />
|
|
3380
|
+
<ForecastCard label={'If usage grows ' + t.forecastScenario + '%'} value={fmtMoney(scenarioCycle)} sub={over ? 'over budget \u26A0' : fmtMoney(BUDGET.limit - scenarioCycle) + ' headroom'} accent={over ? '#f85149' : '#58a6ff'} flag={over} />
|
|
3381
|
+
</div>
|
|
3382
|
+
</section>
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
Object.assign(window, { Ico, Header, BudgetBar, KpiCard, KpiRow, TimeFilter, ForecastCard, ForecastSection });
|
|
3386
|
+
</script>
|
|
2397
3387
|
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
3388
|
+
<script type="text/babel">
|
|
3389
|
+
// tw-table.jsx
|
|
3390
|
+
const { useState: useStateT } = React;
|
|
3391
|
+
function ModelTable({ models, total, t, onRowClick, exampleHover }) {
|
|
3392
|
+
const { fmtInt, fmtUSD, fmtCompact } = window.TW;
|
|
3393
|
+
const [sort, setSort] = useStateT({ key: 'cost', dir: -1 });
|
|
3394
|
+
const [hoverRow, setHoverRow] = useStateT(null);
|
|
3395
|
+
const cols = [
|
|
3396
|
+
{ key: 'id', label: 'Model', align: 'left' },
|
|
3397
|
+
{ key: 'calls', label: 'Calls', align: 'right' },
|
|
3398
|
+
{ key: 'inTok', label: 'In tokens', align: 'right' },
|
|
3399
|
+
{ key: 'outTok', label: 'Out tokens', align: 'right' },
|
|
3400
|
+
{ key: 'cost', label: 'Cost', align: 'right' },
|
|
3401
|
+
{ key: 'share', label: 'Share', align: 'left' },
|
|
3402
|
+
{ key: 'avg', label: 'Avg / call', align: 'right' },
|
|
3403
|
+
];
|
|
3404
|
+
const safeTotal = total || 0.0001;
|
|
3405
|
+
const rows = [...models].map((m) => ({ ...m, avg: m.cost / Math.max(m.calls, 1), share: m.cost / safeTotal }));
|
|
3406
|
+
if (t.tableSort) {
|
|
3407
|
+
rows.sort((a, b) => {
|
|
3408
|
+
const A = a[sort.key], B = b[sort.key];
|
|
3409
|
+
if (typeof A === 'string') return A.localeCompare(B) * sort.dir;
|
|
3410
|
+
return (A - B) * sort.dir;
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
const clickHeader = (key) => {
|
|
3414
|
+
if (!t.tableSort) return;
|
|
3415
|
+
setSort((s) => s.key === key ? { key, dir: -s.dir } : { key, dir: key === 'id' ? 1 : -1 });
|
|
3416
|
+
};
|
|
3417
|
+
return (
|
|
3418
|
+
<section className="tw-section">
|
|
3419
|
+
<div className="tw-sec-head">
|
|
3420
|
+
<h3>Model breakdown</h3>
|
|
3421
|
+
<span className="tw-sec-sub">{models.length} models · click a row for detail</span>
|
|
2403
3422
|
</div>
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
3423
|
+
<div className="tw-table-wrap">
|
|
3424
|
+
<table className={'tw-table' + (t.density === 'compact' ? ' compact' : '')}>
|
|
3425
|
+
<thead>
|
|
3426
|
+
<tr>
|
|
3427
|
+
{cols.map((c) => (
|
|
3428
|
+
<th key={c.key} style={{ textAlign: c.align, cursor: t.tableSort && c.key !== 'share' ? 'pointer' : 'default' }}
|
|
3429
|
+
onClick={() => c.key !== 'share' && clickHeader(c.key)}>
|
|
3430
|
+
<span className="tw-th-inner" style={{ justifyContent: c.align === 'right' ? 'flex-end' : 'flex-start' }}>
|
|
3431
|
+
{c.label}
|
|
3432
|
+
{t.tableSort && sort.key === c.key && <span className="tw-sort">{sort.dir < 0 ? '\u25BE' : '\u25B4'}</span>}
|
|
3433
|
+
</span>
|
|
3434
|
+
</th>
|
|
3435
|
+
))}
|
|
3436
|
+
</tr>
|
|
3437
|
+
</thead>
|
|
3438
|
+
<tbody>
|
|
3439
|
+
{rows.map((m) => {
|
|
3440
|
+
const isExample = exampleHover && m.id === 'claude-sonnet-4-6';
|
|
3441
|
+
return (
|
|
3442
|
+
<tr key={m.id} className={'tw-row' + (isExample ? ' example' : '')}
|
|
3443
|
+
onMouseEnter={() => setHoverRow(m.id)} onMouseLeave={() => setHoverRow(null)}
|
|
3444
|
+
onClick={() => onRowClick(m)}>
|
|
3445
|
+
<td>
|
|
3446
|
+
<div className="tw-model-cell">
|
|
3447
|
+
<span className="tw-model-dot" style={{ background: m.color }} />
|
|
3448
|
+
<span className="tw-model-name">{m.id}</span>
|
|
3449
|
+
<span className="tw-model-prov">{m.provider}</span>
|
|
3450
|
+
</div>
|
|
3451
|
+
</td>
|
|
3452
|
+
<td className="tw-num-cell">{fmtInt(m.calls)}</td>
|
|
3453
|
+
<td className="tw-num-cell">{fmtCompact(m.inTok)}</td>
|
|
3454
|
+
<td className="tw-num-cell">{fmtCompact(m.outTok)}</td>
|
|
3455
|
+
<td className="tw-num-cell tw-cost">{fmtUSD(m.cost, 4)}</td>
|
|
3456
|
+
<td style={{ minWidth: 140 }}><ShareBar frac={m.share} color={m.color} /></td>
|
|
3457
|
+
<td className="tw-num-cell">
|
|
3458
|
+
{hoverRow === m.id && t.tableSort ? (
|
|
3459
|
+
<div className="tw-row-actions" onClick={(e) => e.stopPropagation()}>
|
|
3460
|
+
<button onClick={() => onRowClick(m)}>Details</button>
|
|
3461
|
+
<button>Alert</button>
|
|
3462
|
+
</div>
|
|
3463
|
+
) : (
|
|
3464
|
+
<span>{fmtUSD(m.avg, 4)}</span>
|
|
3465
|
+
)}
|
|
3466
|
+
</td>
|
|
3467
|
+
</tr>
|
|
3468
|
+
);
|
|
3469
|
+
})}
|
|
3470
|
+
</tbody>
|
|
3471
|
+
</table>
|
|
2409
3472
|
</div>
|
|
2410
|
-
</
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
<div id="model-table-wrap"></div>
|
|
2416
|
-
</div>
|
|
2417
|
-
|
|
2418
|
-
<details id="users-section" style="margin-bottom:24px;">
|
|
2419
|
-
<summary>By user</summary>
|
|
2420
|
-
<div id="user-table-wrap"></div>
|
|
2421
|
-
</details>
|
|
3473
|
+
</section>
|
|
3474
|
+
);
|
|
3475
|
+
}
|
|
3476
|
+
Object.assign(window, { ModelTable });
|
|
3477
|
+
</script>
|
|
2422
3478
|
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
3479
|
+
<script type="text/babel">
|
|
3480
|
+
// tw-panel.jsx
|
|
3481
|
+
const { useEffect: useEffectP } = React;
|
|
3482
|
+
function SlideOver({ model, onClose, t }) {
|
|
3483
|
+
const { fmtInt, fmtUSD, callsForModel, fmtAgo } = window.TW;
|
|
3484
|
+
useEffectP(() => {
|
|
3485
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
3486
|
+
window.addEventListener('keydown', onKey);
|
|
3487
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
3488
|
+
}, [onClose]);
|
|
3489
|
+
const open = !!model;
|
|
3490
|
+
const m = model;
|
|
3491
|
+
const calls = m ? callsForModel(m.id, 5) : [];
|
|
3492
|
+
const totalIn = m ? m.inTok : 0, totalOut = m ? m.outTok : 0;
|
|
3493
|
+
return (
|
|
3494
|
+
<>
|
|
3495
|
+
<div className={'tw-scrim' + (open ? ' open' : '')} onClick={onClose} />
|
|
3496
|
+
<aside className={'tw-slideover' + (open ? ' open' : '')}>
|
|
3497
|
+
{m && (
|
|
3498
|
+
<div className="tw-so-inner">
|
|
3499
|
+
<div className="tw-so-head">
|
|
3500
|
+
<div className="tw-so-title">
|
|
3501
|
+
<span className="tw-model-dot lg" style={{ background: m.color }} />
|
|
3502
|
+
<div>
|
|
3503
|
+
<div className="tw-so-name">{m.id}</div>
|
|
3504
|
+
<div className="tw-so-prov">{m.provider} · {fmtInt(m.calls)} calls in window</div>
|
|
3505
|
+
</div>
|
|
3506
|
+
</div>
|
|
3507
|
+
<button className="tw-so-close" onClick={onClose}>✕</button>
|
|
3508
|
+
</div>
|
|
3509
|
+
<div className="tw-so-stats">
|
|
3510
|
+
<div className="tw-so-stat"><div className="tw-so-stat-l">Total cost</div><div className="tw-so-stat-v">{fmtUSD(m.cost, 4)}</div></div>
|
|
3511
|
+
<div className="tw-so-stat"><div className="tw-so-stat-l">Calls</div><div className="tw-so-stat-v">{fmtInt(m.calls)}</div></div>
|
|
3512
|
+
<div className="tw-so-stat"><div className="tw-so-stat-l">Avg / call</div><div className="tw-so-stat-v">{fmtUSD(m.cost / Math.max(m.calls, 1), 4)}</div></div>
|
|
3513
|
+
</div>
|
|
3514
|
+
<div className="tw-so-metarow">
|
|
3515
|
+
<div><span className="tw-so-meta-l">In tokens</span><span className="tw-so-meta-v">{fmtInt(totalIn)}</span></div>
|
|
3516
|
+
<div><span className="tw-so-meta-l">Out tokens</span><span className="tw-so-meta-v">{fmtInt(totalOut)}</span></div>
|
|
3517
|
+
<div><span className="tw-so-meta-l">Cost / call</span><span className="tw-so-meta-v">{fmtUSD(m.cost / Math.max(m.calls, 1), 4)}</span></div>
|
|
3518
|
+
</div>
|
|
3519
|
+
<div className="tw-so-section-label">Last 5 calls (estimated)</div>
|
|
3520
|
+
<div className="tw-so-calls">
|
|
3521
|
+
{calls.map((c) => (
|
|
3522
|
+
<div key={c.id} className="tw-so-call">
|
|
3523
|
+
<div className="tw-so-call-top">
|
|
3524
|
+
<span className="tw-so-call-time">{fmtAgo(c.secondsAgo)}</span>
|
|
3525
|
+
<span className="tw-feat" style={{ color: c.featureColor, borderColor: c.featureColor + '55' }}>{c.feature}</span>
|
|
3526
|
+
<span className="tw-so-call-cost">{fmtUSD(c.cost, 4)}</span>
|
|
3527
|
+
</div>
|
|
3528
|
+
<div className="tw-so-call-bot">
|
|
3529
|
+
<span>{fmtInt(c.inTok)} in · {fmtInt(c.outTok)} out</span>
|
|
3530
|
+
<span className="tw-so-call-lat">{c.latency}ms{c.status === 'slow' ? ' \xB7 slow' : c.status === 'error' ? ' \xB7 error' : ''}</span>
|
|
3531
|
+
</div>
|
|
3532
|
+
</div>
|
|
3533
|
+
))}
|
|
3534
|
+
</div>
|
|
3535
|
+
<button className="tw-so-viewall">View all {fmtInt(m.calls)} calls <Ico.arrow /></button>
|
|
3536
|
+
</div>
|
|
3537
|
+
)}
|
|
3538
|
+
</aside>
|
|
3539
|
+
</>
|
|
3540
|
+
);
|
|
3541
|
+
}
|
|
3542
|
+
Object.assign(window, { SlideOver });
|
|
3543
|
+
</script>
|
|
2427
3544
|
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
3545
|
+
<script type="text/babel">
|
|
3546
|
+
// tw-activity.jsx
|
|
3547
|
+
const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React;
|
|
3548
|
+
function LiveActivity({ t }) {
|
|
3549
|
+
const { seedFeed, makeCall, fmtUSD, fmtInt, fmtAgo } = window.TW;
|
|
3550
|
+
const [feed, setFeed] = useStateA(() => seedFeed(14));
|
|
3551
|
+
const [now, setNow] = useStateA(Date.now());
|
|
3552
|
+
const [paused, setPaused] = useStateA(false);
|
|
3553
|
+
const [collapsed, setCollapsed] = useStateA(false);
|
|
3554
|
+
const [filter, setFilter] = useStateA('all');
|
|
3555
|
+
const seedRef = useRefA(5000);
|
|
3556
|
+
const streaming = t.liveFeed && t.animLevel !== 'minimal' && !paused;
|
|
3557
|
+
useEffectA(() => {
|
|
3558
|
+
if (!streaming) return;
|
|
3559
|
+
const iv = setInterval(() => {
|
|
3560
|
+
const c = makeCall(0, seedRef.current++);
|
|
3561
|
+
c.ts = Date.now(); c.fresh = true;
|
|
3562
|
+
setFeed((f) => [c, ...f].slice(0, 40));
|
|
3563
|
+
}, t.animLevel === 'subtle' ? 3600 : 2100);
|
|
3564
|
+
return () => clearInterval(iv);
|
|
3565
|
+
}, [streaming, t.animLevel]);
|
|
3566
|
+
useEffectA(() => {
|
|
3567
|
+
const iv = setInterval(() => setNow(Date.now()), 1000);
|
|
3568
|
+
return () => clearInterval(iv);
|
|
3569
|
+
}, []);
|
|
3570
|
+
const shown = feed
|
|
3571
|
+
.map((c) => ({ ...c, secondsAgo: Math.max(0, Math.round((now - c.ts) / 1000)) }))
|
|
3572
|
+
.filter((c) => filter === 'all' ? true : filter === 'errors' ? c.status !== 'ok' : c.cost >= 0.001)
|
|
3573
|
+
.slice(0, 10);
|
|
3574
|
+
return (
|
|
3575
|
+
<section className="tw-section tw-activity">
|
|
3576
|
+
<div className="tw-sec-head">
|
|
3577
|
+
<div className="tw-act-title">
|
|
3578
|
+
<button className="tw-collapse" onClick={() => setCollapsed((v) => !v)} style={{ transform: collapsed ? 'rotate(-90deg)' : 'none' }}><Ico.chevron /></button>
|
|
3579
|
+
<span className={'tw-live-dot' + (streaming ? ' on' : '')} />
|
|
3580
|
+
<h3>Live activity</h3>
|
|
3581
|
+
<span className="tw-sec-sub">{streaming ? 'streaming' : t.liveFeed ? 'paused' : 'static'}</span>
|
|
3582
|
+
</div>
|
|
3583
|
+
<div className="tw-act-controls">
|
|
3584
|
+
<div className="tw-seg-sm">
|
|
3585
|
+
{[['all', 'All'], ['signal', 'Signal'], ['errors', 'Errors']].map(([k, l]) => (
|
|
3586
|
+
<button key={k} className={filter === k ? 'on' : ''} onClick={() => setFilter(k)}>{l}</button>
|
|
3587
|
+
))}
|
|
3588
|
+
</div>
|
|
3589
|
+
{t.liveFeed && (
|
|
3590
|
+
<button className="tw-act-pause" onClick={() => setPaused((p) => !p)}>{paused ? '\u25B6 Resume' : '\u275A\u275A Pause'}</button>
|
|
3591
|
+
)}
|
|
3592
|
+
</div>
|
|
2435
3593
|
</div>
|
|
2436
|
-
|
|
2437
|
-
<div
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
3594
|
+
{!collapsed && (
|
|
3595
|
+
<div className="tw-feed">
|
|
3596
|
+
<div className="tw-feed-head">
|
|
3597
|
+
<span>Time</span><span>Model</span><span>Session</span><span>Feature</span><span style={{ textAlign: 'right' }}>Cost</span>
|
|
3598
|
+
</div>
|
|
3599
|
+
<div className="tw-feed-body">
|
|
3600
|
+
{shown.map((c) => (
|
|
3601
|
+
<div key={c.id} className={'tw-feed-row' + (c.fresh ? ' fresh' : '') + (c.status === 'error' ? ' err' : '')}>
|
|
3602
|
+
<span className="tw-feed-time">{c.secondsAgo === 0 ? 'now' : fmtAgo(c.secondsAgo)}</span>
|
|
3603
|
+
<span className="tw-feed-model"><span className="tw-model-dot" style={{ background: c.modelColor }} />{c.model}</span>
|
|
3604
|
+
<span className="tw-feed-sess">{c.session}</span>
|
|
3605
|
+
<span><span className="tw-feat sm" style={{ color: c.featureColor, borderColor: c.featureColor + '55' }}>{c.feature}</span></span>
|
|
3606
|
+
<span className="tw-feed-cost">{fmtUSD(c.cost, 4)}{c.status === 'error' && <span className="tw-feed-flag">err</span>}{c.status === 'slow' && <span className="tw-feed-flag slow">slow</span>}</span>
|
|
3607
|
+
</div>
|
|
3608
|
+
))}
|
|
3609
|
+
</div>
|
|
3610
|
+
</div>
|
|
3611
|
+
)}
|
|
3612
|
+
</section>
|
|
3613
|
+
);
|
|
3614
|
+
}
|
|
3615
|
+
function CommandPalette({ open, onClose, onAction }) {
|
|
3616
|
+
const [q, setQ] = useStateA('');
|
|
3617
|
+
const inputRef = useRefA(null);
|
|
3618
|
+
useEffectA(() => { if (open && inputRef.current) inputRef.current.focus(); if (open) setQ(''); }, [open]);
|
|
3619
|
+
useEffectA(() => {
|
|
3620
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
3621
|
+
if (open) window.addEventListener('keydown', onKey);
|
|
3622
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
3623
|
+
}, [open, onClose]);
|
|
3624
|
+
const items = [
|
|
3625
|
+
{ g: 'Filter', label: 'Set range: Last 1h', act: { range: '1h' } },
|
|
3626
|
+
{ g: 'Filter', label: 'Set range: Last 24h', act: { range: '24h' } },
|
|
3627
|
+
{ g: 'Filter', label: 'Set range: Last 7 days', act: { range: '7d' } },
|
|
3628
|
+
{ g: 'Filter', label: 'Set range: Last 30 days', act: { range: '30d' } },
|
|
3629
|
+
{ g: 'Filter', label: 'Set range: All time', act: { range: 'All' } },
|
|
3630
|
+
{ g: 'Action', label: 'Export CSV', hint: '\u2318E' },
|
|
3631
|
+
{ g: 'Action', label: 'Create budget alert' },
|
|
3632
|
+
];
|
|
3633
|
+
const filtered = items.filter((i) => i.label.toLowerCase().includes(q.toLowerCase()));
|
|
3634
|
+
if (!open) return null;
|
|
3635
|
+
return (
|
|
3636
|
+
<div className="tw-cmdk-scrim" onClick={onClose}>
|
|
3637
|
+
<div className="tw-cmdk" onClick={(e) => e.stopPropagation()}>
|
|
3638
|
+
<div className="tw-cmdk-input">
|
|
3639
|
+
<Ico.search style={{ color: '#7d8590' }} />
|
|
3640
|
+
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search models, sessions, actions\u2026" />
|
|
3641
|
+
<kbd>esc</kbd>
|
|
3642
|
+
</div>
|
|
3643
|
+
<div className="tw-cmdk-list">
|
|
3644
|
+
{filtered.length === 0 && <div className="tw-cmdk-empty">No results for "{q}"</div>}
|
|
3645
|
+
{filtered.map((i, idx) => (
|
|
3646
|
+
<div key={idx} className="tw-cmdk-item" onClick={() => { onAction(i.act); onClose(); }}>
|
|
3647
|
+
<span className="tw-cmdk-g">{i.g}</span>
|
|
3648
|
+
<span className="tw-cmdk-l">{i.label}</span>
|
|
3649
|
+
{i.hint && <kbd>{i.hint}</kbd>}
|
|
3650
|
+
</div>
|
|
3651
|
+
))}
|
|
3652
|
+
</div>
|
|
2443
3653
|
</div>
|
|
2444
3654
|
</div>
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
</div><!-- /container -->
|
|
2450
|
-
|
|
2451
|
-
<script>
|
|
2452
|
-
(function () {
|
|
2453
|
-
'use strict';
|
|
2454
|
-
|
|
2455
|
-
// \u2500\u2500 Palette \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2456
|
-
const PALETTE = [
|
|
2457
|
-
'#58a6ff','#3fb950','#f78166','#d29922','#bc8cff',
|
|
2458
|
-
'#79c0ff','#56d364','#ffa657','#ff7b72','#a5d6ff',
|
|
2459
|
-
];
|
|
2460
|
-
|
|
2461
|
-
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2462
|
-
let evtSource = null;
|
|
2463
|
-
let activeFilter = '24h';
|
|
2464
|
-
let lineChart = null;
|
|
2465
|
-
let doughnutChart = null;
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
Object.assign(window, { LiveActivity, CommandPalette });
|
|
3658
|
+
</script>
|
|
2466
3659
|
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
3660
|
+
<script type="text/babel">
|
|
3661
|
+
// tw-data.jsx \u2014 mock data + formatters (always-populated baseline)
|
|
3662
|
+
const fmtUSD = (n, d = 4) =>
|
|
3663
|
+
'$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
3664
|
+
const fmtMoney = (n) =>
|
|
3665
|
+
'$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
3666
|
+
const fmtInt = (n) => Math.round(n || 0).toLocaleString('en-US');
|
|
3667
|
+
const fmtCompact = (n) => {
|
|
3668
|
+
n = n || 0;
|
|
3669
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/.?0+$/, '') + 'M';
|
|
3670
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/.?0+$/, '') + 'K';
|
|
3671
|
+
return String(Math.round(n));
|
|
3672
|
+
};
|
|
3673
|
+
const fmtAgo = (s) => {
|
|
3674
|
+
if (s < 60) return s + 's ago';
|
|
3675
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
3676
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
3677
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
3678
|
+
};
|
|
3679
|
+
const BASE_MODELS = [
|
|
3680
|
+
{ id: 'gpt-5-mini', provider: 'OpenAI', color: '#3fb950', calls: 94, inTok: 1040000, outTok: 118000, cost: 0.2110, latency: 590 },
|
|
3681
|
+
{ id: 'claude-sonnet-4-6', provider: 'Anthropic', color: '#bc8cff', calls: 58, inTok: 612000, outTok: 84000, cost: 0.3120, latency: 1840 },
|
|
3682
|
+
{ id: 'gemini-2.5-flash', provider: 'Google', color: '#58a6ff', calls: 47, inTok: 430000, outTok: 56000, cost: 0.1190, latency: 510 },
|
|
3683
|
+
{ id: 'claude-haiku-4-5', provider: 'Anthropic', color: '#f778ba', calls: 38, inTok: 268100, outTok: 38540, cost: 0.1240, latency: 680 },
|
|
3684
|
+
{ id: 'gpt-5.1', provider: 'OpenAI', color: '#e3b341', calls: 18, inTok: 88000, outTok: 12000, cost: 0.0613, latency: 2210 },
|
|
3685
|
+
{ id: 'gemini-2.5-pro', provider: 'Google', color: '#56d4dd', calls: 8, inTok: 42000, outTok: 4000, cost: 0.0200, latency: 1990 },
|
|
3686
|
+
];
|
|
3687
|
+
const RANGES = {
|
|
3688
|
+
'1h': { factor: 0.052, points: 12, label: 'last hour', step: '5 min' },
|
|
3689
|
+
'24h': { factor: 1, points: 24, label: 'last 24h', step: 'hour' },
|
|
3690
|
+
'7d': { factor: 6.4, points: 28, label: 'last 7 days', step: '6h' },
|
|
3691
|
+
'30d': { factor: 53.1, points: 30, label: 'last 30 days',step: 'day' },
|
|
3692
|
+
'All': { factor: 142, points: 26, label: 'all time', step: 'week' },
|
|
3693
|
+
};
|
|
3694
|
+
function modelsForRange(range) {
|
|
3695
|
+
const f = RANGES[range].factor;
|
|
3696
|
+
return BASE_MODELS.map((m) => ({
|
|
3697
|
+
...m,
|
|
3698
|
+
calls: Math.max(1, Math.round(m.calls * f)),
|
|
3699
|
+
inTok: Math.round(m.inTok * f),
|
|
3700
|
+
outTok: Math.round(m.outTok * f),
|
|
3701
|
+
cost: m.cost * f,
|
|
3702
|
+
}));
|
|
3703
|
+
}
|
|
3704
|
+
function kpisForRange(range) {
|
|
3705
|
+
const ms = modelsForRange(range);
|
|
3706
|
+
const sum = (k) => ms.reduce((a, m) => a + m[k], 0);
|
|
3707
|
+
const cost = sum('cost'), calls = sum('calls');
|
|
3708
|
+
return {
|
|
3709
|
+
cost, calls,
|
|
3710
|
+
inTok: sum('inTok'), outTok: sum('outTok'),
|
|
3711
|
+
models: ms,
|
|
3712
|
+
burnHr: cost / ({ '1h': 1, '24h': 24, '7d': 168, '30d': 720, 'All': 3408 }[range]),
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
const DAY_SHAPE = [0.2,0.15,0.12,0.1,0.1,0.15,0.3,0.6,1.0,1.4,1.7,1.8,1.6,1.5,1.9,2.0,1.7,1.3,1.0,0.8,0.6,0.45,0.35,0.25];
|
|
3716
|
+
function shapeFor(n) {
|
|
3717
|
+
const out = [];
|
|
3718
|
+
for (let i = 0; i < n; i++) {
|
|
3719
|
+
const t = (i / n) * DAY_SHAPE.length;
|
|
3720
|
+
const a = DAY_SHAPE[Math.floor(t) % DAY_SHAPE.length];
|
|
3721
|
+
const b = DAY_SHAPE[(Math.floor(t) + 1) % DAY_SHAPE.length];
|
|
3722
|
+
out.push(a + (b - a) * (t - Math.floor(t)));
|
|
2474
3723
|
}
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
3724
|
+
return out;
|
|
3725
|
+
}
|
|
3726
|
+
function buildSeries(total, n, jitterSeed = 1) {
|
|
3727
|
+
const shape = shapeFor(n);
|
|
3728
|
+
const j = shape.map((v, i) => v * (0.82 + 0.36 * Math.abs(Math.sin(i * 12.9898 * jitterSeed))));
|
|
3729
|
+
const s = j.reduce((a, b) => a + b, 0);
|
|
3730
|
+
return j.map((v) => (v / s) * total);
|
|
3731
|
+
}
|
|
3732
|
+
function seriesForRange(range) {
|
|
3733
|
+
const { cost } = kpisForRange(range);
|
|
3734
|
+
const n = RANGES[range].points;
|
|
3735
|
+
return { current: buildSeries(cost, n, 1), previous: buildSeries(cost / 1.12, n, 1.7), n };
|
|
3736
|
+
}
|
|
3737
|
+
const FEATURES = ['chat', 'rag-search', 'summarize', 'classify', 'agent-loop', 'embeddings', 'code-review', 'extract'];
|
|
3738
|
+
const FEATURE_COLOR = {
|
|
3739
|
+
'chat': '#58a6ff', 'rag-search': '#3fb950', 'summarize': '#bc8cff', 'classify': '#e3b341',
|
|
3740
|
+
'agent-loop': '#f778ba', 'embeddings': '#56d4dd', 'code-review': '#ff7b72', 'extract': '#7ee787',
|
|
3741
|
+
};
|
|
3742
|
+
let __callSeq = 48213;
|
|
3743
|
+
function rng(seed) { let x = Math.sin(seed) * 10000; return x - Math.floor(x); }
|
|
3744
|
+
function makeCall(secondsAgo, seed) {
|
|
3745
|
+
const m = BASE_MODELS[Math.floor(rng(seed) * BASE_MODELS.length)];
|
|
3746
|
+
const feat = FEATURES[Math.floor(rng(seed * 1.3) * FEATURES.length)];
|
|
3747
|
+
const inTok = Math.round(800 + rng(seed * 2.1) * 14000);
|
|
3748
|
+
const outTok = Math.round(60 + rng(seed * 3.7) * 2200);
|
|
3749
|
+
const cost = (inTok / 1e6) * 0.4 + (outTok / 1e6) * 3.2;
|
|
3750
|
+
const r = rng(seed * 5.9);
|
|
3751
|
+
const status = r > 0.965 ? 'error' : r > 0.9 ? 'slow' : 'ok';
|
|
3752
|
+
return {
|
|
3753
|
+
id: ++__callSeq, secondsAgo, ts: Date.now() - secondsAgo * 1000,
|
|
3754
|
+
model: m.id, modelColor: m.color,
|
|
3755
|
+
session: 'sess_' + (seed * 7919 % 1e6 | 0).toString(36).padStart(4, '0'),
|
|
3756
|
+
feature: feat, featureColor: FEATURE_COLOR[feat],
|
|
3757
|
+
inTok, outTok, cost, latency: Math.round(m.latency * (0.6 + rng(seed * 8.3) * 1.2)), status,
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
function seedFeed(count) {
|
|
3761
|
+
const arr = [];
|
|
3762
|
+
for (let i = 0; i < count; i++) arr.push(makeCall(2 + i * 7 + Math.floor(rng(i * 3.3) * 6), 100 + i));
|
|
3763
|
+
return arr;
|
|
3764
|
+
}
|
|
3765
|
+
function callsForModel(modelId, count = 5) {
|
|
3766
|
+
const arr = [];
|
|
3767
|
+
let sa = 12;
|
|
3768
|
+
for (let i = 0; i < count; i++) {
|
|
3769
|
+
const c = makeCall(sa, 900 + i + modelId.length);
|
|
3770
|
+
c.model = modelId;
|
|
3771
|
+
c.modelColor = (BASE_MODELS.find((m) => m.id === modelId) || {}).color || '#58a6ff';
|
|
3772
|
+
arr.push(c);
|
|
3773
|
+
sa += 40 + Math.floor(rng(i * 2.2) * 220);
|
|
2481
3774
|
}
|
|
3775
|
+
return arr;
|
|
3776
|
+
}
|
|
3777
|
+
const BUDGET = { used: 45.0, limit: 100.0, daysLeft: 18, cycleDays: 30 };
|
|
2482
3778
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
3779
|
+
Object.assign(window, {
|
|
3780
|
+
TW: {
|
|
3781
|
+
fmtUSD, fmtMoney, fmtInt, fmtCompact, fmtAgo,
|
|
3782
|
+
BASE_MODELS, RANGES, modelsForRange, kpisForRange,
|
|
3783
|
+
seriesForRange, seedFeed, makeCall, callsForModel,
|
|
3784
|
+
FEATURES, FEATURE_COLOR, BUDGET, _buildSeries: buildSeries,
|
|
3785
|
+
},
|
|
3786
|
+
});
|
|
2486
3787
|
|
|
2487
|
-
|
|
2488
|
-
|
|
3788
|
+
// SSE overlay \u2014 patches window.TW functions when real data arrives
|
|
3789
|
+
(function () {
|
|
3790
|
+
var MC = ['#bc8cff','#3fb950','#58a6ff','#f778ba','#e3b341','#56d4dd','#79c0ff','#ffa657','#ff7b72','#a5d6ff'];
|
|
3791
|
+
function guessProv(id) {
|
|
3792
|
+
if (/claude/i.test(id)) return 'Anthropic';
|
|
3793
|
+
if (/gpt|o1|o3|o4/i.test(id)) return 'OpenAI';
|
|
3794
|
+
if (/gemini/i.test(id)) return 'Google';
|
|
3795
|
+
return 'Other';
|
|
2489
3796
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
3797
|
+
function buildRealModels(byModel) {
|
|
3798
|
+
return Object.entries(byModel).map(function(e, i) {
|
|
3799
|
+
var id = e[0], s = e[1];
|
|
3800
|
+
return { id: id, provider: guessProv(id), color: MC[i % MC.length],
|
|
3801
|
+
calls: s.calls || 0, inTok: (s.tokens && s.tokens.input) || 0,
|
|
3802
|
+
outTok: (s.tokens && s.tokens.output) || 0, cost: s.costUSD || 0, latency: 1200 };
|
|
3803
|
+
}).sort(function(a, b) { return b.cost - a.cost; });
|
|
2493
3804
|
}
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
});
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
if (evtSource) { evtSource.close(); evtSource = null; }
|
|
2508
|
-
evtSource = new EventSource('/events?filter=' + encodeURIComponent(activeFilter));
|
|
2509
|
-
evtSource.onmessage = function(e) {
|
|
2510
|
-
try { updateUI(JSON.parse(e.data)); } catch (_) {}
|
|
3805
|
+
function applySSEData(data) {
|
|
3806
|
+
var r = data.report, fc = data.forecast, ts = data.timeSeries || [];
|
|
3807
|
+
if (!r || !r.byModel || Object.keys(r.byModel).length === 0) return;
|
|
3808
|
+
var mods = buildRealModels(r.byModel);
|
|
3809
|
+
var totalCalls = mods.reduce(function(s, m) { return s + m.calls; }, 0);
|
|
3810
|
+
var totalCost = r.totalCostUSD || 0;
|
|
3811
|
+
var totalIn = 0, totalOut = 0;
|
|
3812
|
+
if (r.totalTokens) { totalIn = r.totalTokens.input || 0; totalOut = r.totalTokens.output || 0; }
|
|
3813
|
+
else { mods.forEach(function(m) { totalIn += m.inTok; totalOut += m.outTok; }); }
|
|
3814
|
+
var costs = ts.map(function(b) { return b.cost || 0; });
|
|
3815
|
+
window.TW.kpisForRange = function() {
|
|
3816
|
+
return { cost: totalCost, calls: totalCalls, inTok: totalIn, outTok: totalOut,
|
|
3817
|
+
models: mods, burnHr: (fc && fc.burnRatePerHour) || 0 };
|
|
2511
3818
|
};
|
|
3819
|
+
if (costs.length >= 2) {
|
|
3820
|
+
window.TW.seriesForRange = function() {
|
|
3821
|
+
return { current: costs, previous: buildSeries(totalCost / 1.12, costs.length, 1.7), n: costs.length };
|
|
3822
|
+
};
|
|
3823
|
+
}
|
|
3824
|
+
if (fc && fc.projectedDailyCostUSD) window.TW.BUDGET.daily = fc.projectedDailyCostUSD;
|
|
3825
|
+
if (fc && fc.burnRatePerHour) {
|
|
3826
|
+
var elapsed = window.TW.BUDGET.cycleDays - window.TW.BUDGET.daysLeft;
|
|
3827
|
+
window.TW.BUDGET.used = fc.burnRatePerHour * 24 * Math.max(elapsed, 1);
|
|
3828
|
+
}
|
|
3829
|
+
window.dispatchEvent(new CustomEvent('tw-data-update'));
|
|
3830
|
+
}
|
|
3831
|
+
var evtSource = null;
|
|
3832
|
+
function connect(filter) {
|
|
3833
|
+
if (evtSource) { try { evtSource.close(); } catch(e) {} }
|
|
3834
|
+
var f = filter === 'All' ? 'all' : filter;
|
|
3835
|
+
evtSource = new EventSource('/events?filter=' + encodeURIComponent(f));
|
|
3836
|
+
evtSource.onmessage = function(e) { try { applySSEData(JSON.parse(e.data)); } catch(_) {} };
|
|
2512
3837
|
evtSource.onerror = function() {
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
evtSource.onopen = function() {
|
|
2516
|
-
document.getElementById('live-label').textContent = 'live';
|
|
3838
|
+
try { evtSource.close(); } catch(e) {}
|
|
3839
|
+
setTimeout(function() { connect(f); }, 5000);
|
|
2517
3840
|
};
|
|
2518
3841
|
}
|
|
3842
|
+
window.__twSSEConnect = connect;
|
|
3843
|
+
connect('24h');
|
|
3844
|
+
})();
|
|
3845
|
+
</script>
|
|
2519
3846
|
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
const fc = data.forecast;
|
|
2524
|
-
const ts = data.timeSeries;
|
|
2525
|
-
|
|
2526
|
-
// Cards
|
|
2527
|
-
document.getElementById('card-cost').textContent = fmtUSD(r.totalCostUSD);
|
|
2528
|
-
document.getElementById('card-input').textContent = fmtNum(r.totalTokens.input);
|
|
2529
|
-
document.getElementById('card-output').textContent = fmtNum(r.totalTokens.output);
|
|
2530
|
-
document.getElementById('card-calls').textContent = fmtNum(totalCalls(r.byModel));
|
|
2531
|
-
document.getElementById('card-burn').textContent = fmtUSD(fc.burnRatePerHour);
|
|
2532
|
-
document.getElementById('card-period').textContent =
|
|
2533
|
-
r.period.from !== r.period.to
|
|
2534
|
-
? fmtDate(r.period.from) + ' \u2013 ' + fmtDate(r.period.to)
|
|
2535
|
-
: '';
|
|
3847
|
+
<script type="text/babel">
|
|
3848
|
+
// tw-app.jsx
|
|
3849
|
+
const { useState: useStateApp, useEffect: useEffectApp, useMemo: useMemoApp } = React;
|
|
2536
3850
|
|
|
2537
|
-
// Line chart
|
|
2538
|
-
const labels = ts.map(function(b) {
|
|
2539
|
-
try { return new Date(b.bucket).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
|
2540
|
-
catch { return b.bucket; }
|
|
2541
|
-
});
|
|
2542
|
-
const costs = ts.map(function(b) { return b.cost; });
|
|
2543
3851
|
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
3852
|
+
function LoadingSkeleton() {
|
|
3853
|
+
return (
|
|
3854
|
+
<div className="tw-skel-wrap">
|
|
3855
|
+
<div className="tw-skel" style={{ height: 56 }} />
|
|
3856
|
+
<div className="tw-kpis">{[0,0,0,0,0].map((_,i) => <div key={i} className="tw-skel" style={{ height: 96 }} />)}</div>
|
|
3857
|
+
<div className="tw-charts">
|
|
3858
|
+
<div className="tw-skel tw-chart-main" style={{ height: 320 }} />
|
|
3859
|
+
<div className="tw-skel tw-chart-side" style={{ height: 320 }} />
|
|
3860
|
+
</div>
|
|
3861
|
+
<div className="tw-skel" style={{ height: 260 }} />
|
|
3862
|
+
</div>
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
function EmptyState() {
|
|
3866
|
+
return (
|
|
3867
|
+
<div className="tw-empty">
|
|
3868
|
+
<div className="tw-empty-art">
|
|
3869
|
+
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
|
|
3870
|
+
<rect x="8" y="14" width="48" height="36" rx="4" stroke="#30363d" strokeWidth="2" />
|
|
3871
|
+
<path d="M16 40l8-9 7 6 9-13 8 10" stroke="#484f58" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
3872
|
+
<circle cx="24" cy="31" r="2" fill="#484f58" />
|
|
3873
|
+
</svg>
|
|
3874
|
+
</div>
|
|
3875
|
+
<h2>No usage yet</h2>
|
|
3876
|
+
<p>Once your app starts making LLM calls through tokenwatch, cost & token metrics show up here in real time.</p>
|
|
3877
|
+
<div className="tw-empty-actions">
|
|
3878
|
+
<button className="tw-btn-primary">View integration guide <Ico.arrow /></button>
|
|
3879
|
+
<button className="tw-btn-2">Copy API key</button>
|
|
3880
|
+
</div>
|
|
3881
|
+
<div className="tw-empty-snippet">
|
|
3882
|
+
<span className="tw-snip-c">import</span> TokenWatch <span className="tw-snip-c">from</span> <span className="tw-snip-s">'@diogonzafe/tokenwatch'</span>
|
|
3883
|
+
</div>
|
|
3884
|
+
</div>
|
|
3885
|
+
);
|
|
3886
|
+
}
|
|
3887
|
+
function ChartsRow({ range, models, kpis, series, t }) {
|
|
3888
|
+
const { fmtUSD, RANGES } = window.TW;
|
|
3889
|
+
const [activeSlice, setActiveSlice] = useStateApp(null);
|
|
3890
|
+
const animate = t.animLevel !== 'minimal';
|
|
3891
|
+
const rangeConfig = RANGES[range] || RANGES['24h'];
|
|
3892
|
+
const safeTotal = kpis.cost || 0.0001;
|
|
3893
|
+
return (
|
|
3894
|
+
<div className="tw-charts">
|
|
3895
|
+
<div className="tw-card tw-chart-main">
|
|
3896
|
+
<div className="tw-sec-head">
|
|
3897
|
+
<h3>Cost over time</h3>
|
|
3898
|
+
<div className="tw-chart-legend">
|
|
3899
|
+
<span><i style={{ background: t.accent }} /> current</span>
|
|
3900
|
+
{t.compareMode && series.previous && <span className="muted"><i className="dash" /> previous</span>}
|
|
3901
|
+
<span className="tw-sec-sub">· per {rangeConfig.step}</span>
|
|
3902
|
+
</div>
|
|
3903
|
+
</div>
|
|
3904
|
+
<LineChart current={series.current} previous={t.compareMode ? series.previous : null}
|
|
3905
|
+
n={series.n} color={t.accent} compare={t.compareMode && !!series.previous}
|
|
3906
|
+
animate={animate} fmt={(v) => fmtUSD(v, 4)} />
|
|
3907
|
+
</div>
|
|
3908
|
+
<div className="tw-card tw-chart-side">
|
|
3909
|
+
<div className="tw-sec-head"><h3>By model</h3></div>
|
|
3910
|
+
<div className="tw-doughnut-wrap">
|
|
3911
|
+
<Doughnut data={models} total={safeTotal} fmt={(v) => fmtUSD(v, 4)} active={activeSlice} onHover={setActiveSlice} />
|
|
3912
|
+
</div>
|
|
3913
|
+
<div className="tw-legend">
|
|
3914
|
+
{[...models].sort((a, b) => b.cost - a.cost).map((m) => (
|
|
3915
|
+
<div key={m.id} className={'tw-legend-row' + (activeSlice === m.id ? ' on' : '')}
|
|
3916
|
+
onMouseEnter={() => setActiveSlice(m.id)} onMouseLeave={() => setActiveSlice(null)}>
|
|
3917
|
+
<span className="tw-model-dot" style={{ background: m.color }} />
|
|
3918
|
+
<span className="tw-legend-name">{m.id}</span>
|
|
3919
|
+
<span className="tw-legend-val">{fmtUSD(m.cost, 4)}</span>
|
|
3920
|
+
<span className="tw-legend-pct">{((m.cost / safeTotal) * 100).toFixed(0)}%</span>
|
|
3921
|
+
</div>
|
|
3922
|
+
))}
|
|
3923
|
+
</div>
|
|
3924
|
+
</div>
|
|
3925
|
+
</div>
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
3929
|
+
"density": "compact",
|
|
3930
|
+
"kpiSparklines": true,
|
|
3931
|
+
"smartHighlight": true,
|
|
3932
|
+
"compareMode": false,
|
|
3933
|
+
"tableSort": true,
|
|
3934
|
+
"budgetAlerts": true,
|
|
3935
|
+
"forecastScenario": 20,
|
|
3936
|
+
"liveFeed": true,
|
|
3937
|
+
"animLevel": "lively",
|
|
3938
|
+
"commandPalette": true,
|
|
3939
|
+
"appState": "data",
|
|
3940
|
+
"accent": "#58a6ff"
|
|
3941
|
+
}/*EDITMODE-END*/;
|
|
3942
|
+
function App() {
|
|
3943
|
+
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
3944
|
+
const [range, setRange] = useStateApp('24h');
|
|
3945
|
+
const [selModel, setSelModel] = useStateApp(null);
|
|
3946
|
+
const [paletteOpen, setPaletteOpen] = useStateApp(false);
|
|
3947
|
+
const [_sseV, setSseV] = useStateApp(0);
|
|
2582
3948
|
|
|
2583
|
-
|
|
2584
|
-
const
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
3949
|
+
useEffectApp(() => {
|
|
3950
|
+
const h = function() { setSseV(function(v) { return v + 1; }); };
|
|
3951
|
+
window.addEventListener('tw-data-update', h);
|
|
3952
|
+
return function() { window.removeEventListener('tw-data-update', h); };
|
|
3953
|
+
}, []);
|
|
2588
3954
|
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
type: 'doughnut',
|
|
2593
|
-
data: { labels: dLabels, datasets: [{ data: dData, backgroundColor: dColors, borderWidth: 0, hoverOffset: 4 }] },
|
|
2594
|
-
options: {
|
|
2595
|
-
responsive: true, maintainAspectRatio: false,
|
|
2596
|
-
cutout: '65%',
|
|
2597
|
-
plugins: {
|
|
2598
|
-
legend: {
|
|
2599
|
-
position: 'bottom',
|
|
2600
|
-
labels: { color: '#8b949e', boxWidth: 10, padding: 12, font: { size: 11 } },
|
|
2601
|
-
},
|
|
2602
|
-
},
|
|
2603
|
-
},
|
|
2604
|
-
});
|
|
2605
|
-
} else {
|
|
2606
|
-
doughnutChart.data.labels = dLabels;
|
|
2607
|
-
doughnutChart.data.datasets[0].data = dData;
|
|
2608
|
-
doughnutChart.data.datasets[0].backgroundColor = dColors;
|
|
2609
|
-
doughnutChart.update('none');
|
|
2610
|
-
}
|
|
3955
|
+
useEffectApp(() => {
|
|
3956
|
+
if (window.__twSSEConnect) window.__twSSEConnect(range);
|
|
3957
|
+
}, [range]);
|
|
2611
3958
|
|
|
2612
|
-
|
|
2613
|
-
const
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
} else {
|
|
2617
|
-
const totalCost = r.totalCostUSD || 1;
|
|
2618
|
-
let html = '<table><thead><tr>' +
|
|
2619
|
-
'<th>Model</th>' +
|
|
2620
|
-
'<th class="num">Calls</th>' +
|
|
2621
|
-
'<th class="num">In tokens</th>' +
|
|
2622
|
-
'<th class="num">Out tokens</th>' +
|
|
2623
|
-
'<th class="num">Cost</th>' +
|
|
2624
|
-
'<th class="num">Share</th>' +
|
|
2625
|
-
'</tr></thead><tbody>';
|
|
2626
|
-
const sorted = modelEntries.slice().sort(function(a, b) { return b[1].costUSD - a[1].costUSD; });
|
|
2627
|
-
sorted.forEach(function(entry) {
|
|
2628
|
-
const name = entry[0]; const m = entry[1];
|
|
2629
|
-
const pct = (m.costUSD / totalCost * 100).toFixed(1);
|
|
2630
|
-
const barW = Math.round(m.costUSD / totalCost * 80);
|
|
2631
|
-
html += '<tr>' +
|
|
2632
|
-
'<td>' + escHtml(name) + '</td>' +
|
|
2633
|
-
'<td class="num">' + fmtNum(m.calls) + '</td>' +
|
|
2634
|
-
'<td class="num">' + fmtNum(m.tokens.input) + '</td>' +
|
|
2635
|
-
'<td class="num">' + fmtNum(m.tokens.output) + '</td>' +
|
|
2636
|
-
'<td class="num">' + fmtUSD(m.costUSD) + '</td>' +
|
|
2637
|
-
'<td class="num">' + pct + '%' +
|
|
2638
|
-
'<span class="bar-wrap"><span class="bar-fill" style="width:' + barW + 'px"></span></span>' +
|
|
2639
|
-
'</td></tr>';
|
|
2640
|
-
});
|
|
2641
|
-
html += '</tbody></table>';
|
|
2642
|
-
modelWrap.innerHTML = html;
|
|
2643
|
-
}
|
|
3959
|
+
const kpis = useMemoApp(() => {
|
|
3960
|
+
const { kpisForRange } = window.TW;
|
|
3961
|
+
return kpisForRange(range);
|
|
3962
|
+
}, [range, _sseV]);
|
|
2644
3963
|
|
|
2645
|
-
|
|
2646
|
-
const
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
if (userEntries.length > 0) {
|
|
2650
|
-
let html = '<table><thead><tr><th>User</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2651
|
-
userEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2652
|
-
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2653
|
-
});
|
|
2654
|
-
html += '</tbody></table>';
|
|
2655
|
-
document.getElementById('user-table-wrap').innerHTML = html;
|
|
2656
|
-
}
|
|
3964
|
+
const series = useMemoApp(() => {
|
|
3965
|
+
const { seriesForRange } = window.TW;
|
|
3966
|
+
return seriesForRange(range);
|
|
3967
|
+
}, [range, _sseV]);
|
|
2657
3968
|
|
|
2658
|
-
|
|
2659
|
-
const featureEntries = Object.entries(r.byFeature);
|
|
2660
|
-
const featuresSection = document.getElementById('features-section');
|
|
2661
|
-
featuresSection.style.display = featureEntries.length === 0 ? 'none' : '';
|
|
2662
|
-
if (featureEntries.length > 0) {
|
|
2663
|
-
let html = '<table><thead><tr><th>Feature</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2664
|
-
featureEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2665
|
-
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2666
|
-
});
|
|
2667
|
-
html += '</tbody></table>';
|
|
2668
|
-
document.getElementById('feature-table-wrap').innerHTML = html;
|
|
2669
|
-
}
|
|
3969
|
+
const models = kpis.models;
|
|
2670
3970
|
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3971
|
+
useEffectApp(() => {
|
|
3972
|
+
const onKey = (e) => {
|
|
3973
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
3974
|
+
e.preventDefault();
|
|
3975
|
+
if (t.commandPalette) setPaletteOpen((v) => !v);
|
|
3976
|
+
}
|
|
3977
|
+
};
|
|
3978
|
+
window.addEventListener('keydown', onKey);
|
|
3979
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
3980
|
+
}, [t.commandPalette]);
|
|
2677
3981
|
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
3982
|
+
const handleAction = (act) => {
|
|
3983
|
+
if (!act) return;
|
|
3984
|
+
if (act.range) setRange(act.range);
|
|
3985
|
+
if (act.model) {
|
|
3986
|
+
const m = models.find((x) => x.id === act.model);
|
|
3987
|
+
if (m) setSelModel({ ...m, share: m.cost / Math.max(kpis.cost, 0.0001) });
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
2682
3990
|
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
})()
|
|
3991
|
+
return (
|
|
3992
|
+
<div className="tw-root" style={{ '--accent': t.accent }}>
|
|
3993
|
+
<Header t={t} onOpenPalette={() => setPaletteOpen(true)} />
|
|
3994
|
+
{t.appState === 'loading' ? (
|
|
3995
|
+
<main className="tw-main"><LoadingSkeleton /></main>
|
|
3996
|
+
) : t.appState === 'empty' ? (
|
|
3997
|
+
<main className="tw-main"><EmptyState /></main>
|
|
3998
|
+
) : (
|
|
3999
|
+
<main className="tw-main">
|
|
4000
|
+
<BudgetBar t={t} />
|
|
4001
|
+
<KpiRow kpis={kpis} range={range} t={t} />
|
|
4002
|
+
<TimeFilter range={range} setRange={setRange} t={t} setTweak={setTweak} />
|
|
4003
|
+
<ChartsRow range={range} models={models} kpis={kpis} series={series} t={t} />
|
|
4004
|
+
<ModelTable models={models} total={kpis.cost} t={t} exampleHover={t.smartHighlight}
|
|
4005
|
+
onRowClick={(m) => setSelModel({ ...m, share: m.cost / Math.max(kpis.cost, 0.0001) })} />
|
|
4006
|
+
<ForecastSection t={t} />
|
|
4007
|
+
<LiveActivity t={t} />
|
|
4008
|
+
</main>
|
|
4009
|
+
)}
|
|
4010
|
+
<SlideOver model={selModel} onClose={() => setSelModel(null)} t={t} />
|
|
4011
|
+
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} onAction={handleAction} />
|
|
4012
|
+
<TweaksPanel title="Tweaks \xB7 UX">
|
|
4013
|
+
<TweakSection label="Hierarquia & densidade" />
|
|
4014
|
+
<TweakRadio label="Densidade" value={t.density} options={[{ value: 'compact', label: 'Compacto' }, { value: 'comfy', label: 'Confort.' }]} onChange={(v) => setTweak('density', v)} />
|
|
4015
|
+
<TweakToggle label="Sparklines nos KPIs" value={t.kpiSparklines} onChange={(v) => setTweak('kpiSparklines', v)} />
|
|
4016
|
+
<TweakToggle label="Smart highlight" value={t.smartHighlight} onChange={(v) => setTweak('smartHighlight', v)} />
|
|
4017
|
+
<TweakSection label="An\xE1lise" />
|
|
4018
|
+
<TweakToggle label="Comparar c/ per\xEDodo anterior" value={t.compareMode} onChange={(v) => setTweak('compareMode', v)} />
|
|
4019
|
+
<TweakToggle label="Sorting + a\xE7\xF5es na tabela" value={t.tableSort} onChange={(v) => setTweak('tableSort', v)} />
|
|
4020
|
+
<TweakSection label="Custo proativo" />
|
|
4021
|
+
<TweakToggle label="Alertas de budget" value={t.budgetAlerts} onChange={(v) => setTweak('budgetAlerts', v)} />
|
|
4022
|
+
<TweakSlider label="Cen\xE1rio: crescimento" value={t.forecastScenario} min={0} max={300} step={5} unit="%" onChange={(v) => setTweak('forecastScenario', v)} />
|
|
4023
|
+
<TweakSection label="Tempo real" />
|
|
4024
|
+
<TweakToggle label="Live feed" value={t.liveFeed} onChange={(v) => setTweak('liveFeed', v)} />
|
|
4025
|
+
<TweakRadio label="Anima\xE7\xE3o" value={t.animLevel} options={[{ value: 'lively', label: 'Vivo' }, { value: 'subtle', label: 'Sutil' }, { value: 'minimal', label: 'M\xEDn.' }]} onChange={(v) => setTweak('animLevel', v)} />
|
|
4026
|
+
<TweakSection label="Navega\xE7\xE3o & estado" />
|
|
4027
|
+
<TweakToggle label="Command palette (\u2318K)" value={t.commandPalette} onChange={(v) => setTweak('commandPalette', v)} />
|
|
4028
|
+
<TweakRadio label="Estado" value={t.appState} options={[{ value: 'data', label: 'Dados' }, { value: 'loading', label: 'Load' }, { value: 'empty', label: 'Vazio' }]} onChange={(v) => setTweak('appState', v)} />
|
|
4029
|
+
<TweakSection label="Visual" />
|
|
4030
|
+
<TweakColor label="Accent" value={t.accent} options={['#58a6ff', '#3fb950', '#bc8cff', '#f778ba']} onChange={(v) => setTweak('accent', v)} />
|
|
4031
|
+
</TweaksPanel>
|
|
4032
|
+
</div>
|
|
4033
|
+
);
|
|
4034
|
+
}
|
|
4035
|
+
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
|
|
2686
4036
|
</script>
|
|
2687
4037
|
</body>
|
|
2688
4038
|
</html>`;
|
|
@@ -2746,7 +4096,82 @@ function startDashboardServer(storage, port) {
|
|
|
2746
4096
|
|
|
2747
4097
|
// bin/cli.ts
|
|
2748
4098
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2749
|
-
var
|
|
4099
|
+
var DEFAULT_DB_PATH = join3(homedir3(), ".tokenwatch", "usage.db");
|
|
4100
|
+
function getFlag(args, flag) {
|
|
4101
|
+
const idx = args.indexOf(flag);
|
|
4102
|
+
if (idx === -1) return void 0;
|
|
4103
|
+
const value = args[idx + 1];
|
|
4104
|
+
return value !== void 0 && !value.startsWith("--") ? value : void 0;
|
|
4105
|
+
}
|
|
4106
|
+
async function openStorage(dbUrl) {
|
|
4107
|
+
if (!dbUrl) {
|
|
4108
|
+
if (!existsSync2(DEFAULT_DB_PATH)) {
|
|
4109
|
+
console.error(`No SQLite database found at ${DEFAULT_DB_PATH}`);
|
|
4110
|
+
console.error("Start your app with storage: 'sqlite' to begin recording usage.");
|
|
4111
|
+
console.error("Or pass --db <url> to connect to Postgres, MySQL, or MongoDB.");
|
|
4112
|
+
process.exit(1);
|
|
4113
|
+
}
|
|
4114
|
+
let storage;
|
|
4115
|
+
try {
|
|
4116
|
+
storage = new SqliteStorage(DEFAULT_DB_PATH);
|
|
4117
|
+
} catch {
|
|
4118
|
+
console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
|
|
4119
|
+
console.error("Run: npm install better-sqlite3");
|
|
4120
|
+
process.exit(1);
|
|
4121
|
+
}
|
|
4122
|
+
return { storage, close: async () => {
|
|
4123
|
+
} };
|
|
4124
|
+
}
|
|
4125
|
+
if (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://")) {
|
|
4126
|
+
let pgMod;
|
|
4127
|
+
try {
|
|
4128
|
+
pgMod = (await import("pg")).default;
|
|
4129
|
+
} catch {
|
|
4130
|
+
console.error("[tokenwatch] Postgres requires the pg package.");
|
|
4131
|
+
console.error("Run: npm install pg");
|
|
4132
|
+
process.exit(1);
|
|
4133
|
+
}
|
|
4134
|
+
const { PostgresStorage: PostgresStorage2 } = await Promise.resolve().then(() => (init_postgres(), postgres_exports));
|
|
4135
|
+
const pool = new pgMod.Pool({ connectionString: dbUrl });
|
|
4136
|
+
const storage = new PostgresStorage2(pool);
|
|
4137
|
+
return { storage, close: () => pool.end() };
|
|
4138
|
+
}
|
|
4139
|
+
if (dbUrl.startsWith("mysql://")) {
|
|
4140
|
+
let mysqlMod;
|
|
4141
|
+
try {
|
|
4142
|
+
mysqlMod = await import("mysql2/promise");
|
|
4143
|
+
} catch {
|
|
4144
|
+
console.error("[tokenwatch] MySQL requires the mysql2 package.");
|
|
4145
|
+
console.error("Run: npm install mysql2");
|
|
4146
|
+
process.exit(1);
|
|
4147
|
+
}
|
|
4148
|
+
const { MySQLStorage: MySQLStorage2 } = await Promise.resolve().then(() => (init_mysql(), mysql_exports));
|
|
4149
|
+
const pool = mysqlMod.createPool(dbUrl);
|
|
4150
|
+
const storage = new MySQLStorage2(pool);
|
|
4151
|
+
return { storage, close: () => pool.end() };
|
|
4152
|
+
}
|
|
4153
|
+
if (dbUrl.startsWith("mongodb://") || dbUrl.startsWith("mongodb+srv://")) {
|
|
4154
|
+
let mongoMod;
|
|
4155
|
+
try {
|
|
4156
|
+
mongoMod = await import("mongodb");
|
|
4157
|
+
} catch {
|
|
4158
|
+
console.error("[tokenwatch] MongoDB requires the mongodb package.");
|
|
4159
|
+
console.error("Run: npm install mongodb");
|
|
4160
|
+
process.exit(1);
|
|
4161
|
+
}
|
|
4162
|
+
const { MongoStorage: MongoStorage2 } = await Promise.resolve().then(() => (init_mongodb(), mongodb_exports));
|
|
4163
|
+
const urlObj = new URL(dbUrl);
|
|
4164
|
+
const dbName = urlObj.pathname.replace(/^\//, "") || "tokenwatch";
|
|
4165
|
+
const client = new mongoMod.MongoClient(dbUrl);
|
|
4166
|
+
await client.connect();
|
|
4167
|
+
const db = client.db(dbName);
|
|
4168
|
+
const storage = new MongoStorage2(db);
|
|
4169
|
+
return { storage, close: () => client.close() };
|
|
4170
|
+
}
|
|
4171
|
+
console.error(`[tokenwatch] Unsupported database URL: "${dbUrl}"`);
|
|
4172
|
+
console.error("Supported protocols: postgres://, mysql://, mongodb://, mongodb+srv://");
|
|
4173
|
+
process.exit(1);
|
|
4174
|
+
}
|
|
2750
4175
|
function loadBundledPrices() {
|
|
2751
4176
|
const pricesPath = join3(__dirname, "..", "prices.json");
|
|
2752
4177
|
const raw = readFileSync(pricesPath, "utf8");
|
|
@@ -2779,22 +4204,16 @@ function cmdPrices() {
|
|
|
2779
4204
|
console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`);
|
|
2780
4205
|
}
|
|
2781
4206
|
}
|
|
2782
|
-
async function cmdReport() {
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
return;
|
|
2787
|
-
}
|
|
2788
|
-
let storage;
|
|
4207
|
+
async function cmdReport(args) {
|
|
4208
|
+
const dbUrl = getFlag(args, "--db");
|
|
4209
|
+
const { storage, close } = await openStorage(dbUrl);
|
|
4210
|
+
let report;
|
|
2789
4211
|
try {
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
process.exit(1);
|
|
4212
|
+
const tracker = createTracker({ storage, syncPrices: false });
|
|
4213
|
+
report = await tracker.getReport();
|
|
4214
|
+
} finally {
|
|
4215
|
+
await close();
|
|
2795
4216
|
}
|
|
2796
|
-
const tracker = createTracker({ storage, syncPrices: false });
|
|
2797
|
-
const report = await tracker.getReport();
|
|
2798
4217
|
if (report.totalCostUSD === 0 && Object.keys(report.byModel).length === 0) {
|
|
2799
4218
|
console.log("No usage recorded yet.");
|
|
2800
4219
|
return;
|
|
@@ -2832,20 +4251,20 @@ async function cmdReport() {
|
|
|
2832
4251
|
}
|
|
2833
4252
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
|
|
2834
4253
|
}
|
|
2835
|
-
async function cmdDashboard(
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
}
|
|
2841
|
-
let storage;
|
|
2842
|
-
try {
|
|
2843
|
-
storage = new SqliteStorage(DB_PATH2);
|
|
2844
|
-
} catch {
|
|
2845
|
-
console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
|
|
2846
|
-
console.error("Run: npm install better-sqlite3");
|
|
4254
|
+
async function cmdDashboard(args) {
|
|
4255
|
+
const portFlag = getFlag(args, "--port");
|
|
4256
|
+
const port = portFlag !== void 0 ? parseInt(portFlag, 10) : 4242;
|
|
4257
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
4258
|
+
console.error(`[tokenwatch] Invalid port: "${portFlag}". Must be a number between 1 and 65535.`);
|
|
2847
4259
|
process.exit(1);
|
|
2848
4260
|
}
|
|
4261
|
+
const dbUrl = getFlag(args, "--db");
|
|
4262
|
+
const { storage, close } = await openStorage(dbUrl);
|
|
4263
|
+
const shutdown = () => {
|
|
4264
|
+
void close().then(() => process.exit(0));
|
|
4265
|
+
};
|
|
4266
|
+
process.on("SIGINT", shutdown);
|
|
4267
|
+
process.on("SIGTERM", shutdown);
|
|
2849
4268
|
startDashboardServer(storage, port);
|
|
2850
4269
|
}
|
|
2851
4270
|
function cmdHelp() {
|
|
@@ -2853,11 +4272,23 @@ function cmdHelp() {
|
|
|
2853
4272
|
tokenwatch \u2014 CLI
|
|
2854
4273
|
|
|
2855
4274
|
Commands:
|
|
2856
|
-
sync
|
|
2857
|
-
prices
|
|
2858
|
-
report
|
|
2859
|
-
dashboard [--port N]
|
|
2860
|
-
|
|
4275
|
+
sync Fetch and cache latest model prices from remote
|
|
4276
|
+
prices List all bundled models and their current prices
|
|
4277
|
+
report [--db <url>] Show usage report (default: SQLite at ~/.tokenwatch/usage.db)
|
|
4278
|
+
dashboard [--port N] Open local web dashboard (default port: 4242)
|
|
4279
|
+
[--db <url>] Connect to a database instead of the default SQLite
|
|
4280
|
+
|
|
4281
|
+
Database URL formats:
|
|
4282
|
+
(none) ~/.tokenwatch/usage.db (SQLite, default)
|
|
4283
|
+
postgres://user:pass@host:5432/dbname
|
|
4284
|
+
mysql://user:pass@host:3306/dbname
|
|
4285
|
+
mongodb://user:pass@host:27017/dbname
|
|
4286
|
+
|
|
4287
|
+
Examples:
|
|
4288
|
+
tokenwatch dashboard
|
|
4289
|
+
tokenwatch dashboard --port 8080
|
|
4290
|
+
tokenwatch dashboard --db postgres://user:pass@localhost:5432/myapp
|
|
4291
|
+
tokenwatch report --db mysql://root:pass@localhost:3306/myapp
|
|
2861
4292
|
`.trim());
|
|
2862
4293
|
}
|
|
2863
4294
|
async function main() {
|
|
@@ -2870,14 +4301,11 @@ async function main() {
|
|
|
2870
4301
|
cmdPrices();
|
|
2871
4302
|
break;
|
|
2872
4303
|
case "report":
|
|
2873
|
-
await cmdReport();
|
|
4304
|
+
await cmdReport(args);
|
|
2874
4305
|
break;
|
|
2875
|
-
case "dashboard":
|
|
2876
|
-
|
|
2877
|
-
const port = portFlagIdx !== -1 ? parseInt(args[portFlagIdx + 1] ?? "4242", 10) : 4242;
|
|
2878
|
-
await cmdDashboard(port);
|
|
4306
|
+
case "dashboard":
|
|
4307
|
+
await cmdDashboard(args);
|
|
2879
4308
|
break;
|
|
2880
|
-
}
|
|
2881
4309
|
case "help":
|
|
2882
4310
|
case void 0:
|
|
2883
4311
|
cmdHelp();
|