@diogonzafe/tokenwatch 0.7.0 → 0.9.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 +13 -10
- 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 +1622 -518
- 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-fD5QLTWg.d.cts} +16 -0
- package/dist/{index-D9xq0RNg.d.ts → index-fD5QLTWg.d.ts} +16 -0
- package/dist/index.cjs +164 -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 +164 -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 +96 -9
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ function rowToEntry(r) {
|
|
|
29
29
|
...r["session_id"] != null && { sessionId: r["session_id"] },
|
|
30
30
|
...r["user_id"] != null && { userId: r["user_id"] },
|
|
31
31
|
...r["feature"] != null && { feature: r["feature"] },
|
|
32
|
+
...r["app_id"] != null && { appId: r["app_id"] },
|
|
32
33
|
timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
|
|
33
34
|
};
|
|
34
35
|
}
|
|
@@ -57,6 +58,7 @@ var init_postgres = __esm({
|
|
|
57
58
|
session_id TEXT,
|
|
58
59
|
user_id TEXT,
|
|
59
60
|
feature TEXT,
|
|
61
|
+
app_id TEXT,
|
|
60
62
|
timestamp TIMESTAMPTZ NOT NULL
|
|
61
63
|
)
|
|
62
64
|
`);
|
|
@@ -64,7 +66,8 @@ var init_postgres = __esm({
|
|
|
64
66
|
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS reasoning_tokens INTEGER NOT NULL DEFAULT 0",
|
|
65
67
|
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS feature TEXT",
|
|
66
68
|
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cached_tokens INTEGER NOT NULL DEFAULT 0",
|
|
67
|
-
"ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cache_creation_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"
|
|
68
71
|
]) {
|
|
69
72
|
await this.client.query(col).catch(() => {
|
|
70
73
|
});
|
|
@@ -74,8 +77,8 @@ var init_postgres = __esm({
|
|
|
74
77
|
this.client.query(
|
|
75
78
|
`INSERT INTO tokenwatch_usage
|
|
76
79
|
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
77
|
-
cost_usd, session_id, user_id, feature, timestamp)
|
|
78
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
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)`,
|
|
79
82
|
[
|
|
80
83
|
entry.model,
|
|
81
84
|
entry.inputTokens,
|
|
@@ -87,6 +90,7 @@ var init_postgres = __esm({
|
|
|
87
90
|
entry.sessionId ?? null,
|
|
88
91
|
entry.userId ?? null,
|
|
89
92
|
entry.feature ?? null,
|
|
93
|
+
entry.appId ?? null,
|
|
90
94
|
entry.timestamp
|
|
91
95
|
]
|
|
92
96
|
).catch((err) => {
|
|
@@ -132,6 +136,7 @@ function rowToEntry2(r) {
|
|
|
132
136
|
...r["session_id"] != null && { sessionId: r["session_id"] },
|
|
133
137
|
...r["user_id"] != null && { userId: r["user_id"] },
|
|
134
138
|
...r["feature"] != null && { feature: r["feature"] },
|
|
139
|
+
...r["app_id"] != null && { appId: r["app_id"] },
|
|
135
140
|
timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
|
|
136
141
|
};
|
|
137
142
|
}
|
|
@@ -160,6 +165,7 @@ var init_mysql = __esm({
|
|
|
160
165
|
session_id VARCHAR(255),
|
|
161
166
|
user_id VARCHAR(255),
|
|
162
167
|
feature VARCHAR(255),
|
|
168
|
+
app_id VARCHAR(255),
|
|
163
169
|
timestamp DATETIME(3) NOT NULL
|
|
164
170
|
)
|
|
165
171
|
`);
|
|
@@ -168,7 +174,8 @@ var init_mysql = __esm({
|
|
|
168
174
|
ADD COLUMN IF NOT EXISTS reasoning_tokens INT NOT NULL DEFAULT 0,
|
|
169
175
|
ADD COLUMN IF NOT EXISTS feature VARCHAR(255),
|
|
170
176
|
ADD COLUMN IF NOT EXISTS cached_tokens INT NOT NULL DEFAULT 0,
|
|
171
|
-
ADD COLUMN IF NOT EXISTS cache_creation_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)
|
|
172
179
|
`).catch(() => {
|
|
173
180
|
});
|
|
174
181
|
}
|
|
@@ -176,8 +183,8 @@ var init_mysql = __esm({
|
|
|
176
183
|
this.client.execute(
|
|
177
184
|
`INSERT INTO tokenwatch_usage
|
|
178
185
|
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
179
|
-
cost_usd, session_id, user_id, feature, timestamp)
|
|
180
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
186
|
+
cost_usd, session_id, user_id, feature, app_id, timestamp)
|
|
187
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
181
188
|
[
|
|
182
189
|
entry.model,
|
|
183
190
|
entry.inputTokens,
|
|
@@ -189,6 +196,7 @@ var init_mysql = __esm({
|
|
|
189
196
|
entry.sessionId ?? null,
|
|
190
197
|
entry.userId ?? null,
|
|
191
198
|
entry.feature ?? null,
|
|
199
|
+
entry.appId ?? null,
|
|
192
200
|
entry.timestamp
|
|
193
201
|
]
|
|
194
202
|
).catch((err) => {
|
|
@@ -231,6 +239,7 @@ function docToEntry(doc) {
|
|
|
231
239
|
...doc.sessionId != null && { sessionId: doc.sessionId },
|
|
232
240
|
...doc.userId != null && { userId: doc.userId },
|
|
233
241
|
...doc.feature != null && { feature: doc.feature },
|
|
242
|
+
...doc.appId != null && { appId: doc.appId },
|
|
234
243
|
timestamp: doc.timestamp
|
|
235
244
|
};
|
|
236
245
|
}
|
|
@@ -250,6 +259,7 @@ var init_mongodb = __esm({
|
|
|
250
259
|
await this.col.createIndex({ sessionId: 1 });
|
|
251
260
|
await this.col.createIndex({ userId: 1 });
|
|
252
261
|
await this.col.createIndex({ model: 1 });
|
|
262
|
+
await this.col.createIndex({ appId: 1 });
|
|
253
263
|
}
|
|
254
264
|
record(entry) {
|
|
255
265
|
this.col.insertOne({
|
|
@@ -263,6 +273,7 @@ var init_mongodb = __esm({
|
|
|
263
273
|
sessionId: entry.sessionId ?? null,
|
|
264
274
|
userId: entry.userId ?? null,
|
|
265
275
|
...entry.feature !== void 0 && { feature: entry.feature },
|
|
276
|
+
...entry.appId !== void 0 && { appId: entry.appId },
|
|
266
277
|
timestamp: entry.timestamp
|
|
267
278
|
}).catch((err) => {
|
|
268
279
|
console.warn("[tokenwatch] MongoStorage.record failed:", err);
|
|
@@ -392,6 +403,7 @@ var SqliteStorage = class {
|
|
|
392
403
|
session_id TEXT,
|
|
393
404
|
user_id TEXT,
|
|
394
405
|
feature TEXT,
|
|
406
|
+
app_id TEXT,
|
|
395
407
|
timestamp TEXT NOT NULL
|
|
396
408
|
)
|
|
397
409
|
`);
|
|
@@ -408,13 +420,16 @@ var SqliteStorage = class {
|
|
|
408
420
|
if (!cols.includes("cache_creation_tokens")) {
|
|
409
421
|
this.db.exec(`ALTER TABLE usage ADD COLUMN cache_creation_tokens INTEGER NOT NULL DEFAULT 0`);
|
|
410
422
|
}
|
|
423
|
+
if (!cols.includes("app_id")) {
|
|
424
|
+
this.db.exec(`ALTER TABLE usage ADD COLUMN app_id TEXT`);
|
|
425
|
+
}
|
|
411
426
|
}
|
|
412
427
|
record(entry) {
|
|
413
428
|
this.db.prepare(
|
|
414
429
|
`INSERT INTO usage
|
|
415
430
|
(model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
|
|
416
|
-
cost_usd, session_id, user_id, feature, timestamp)
|
|
417
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
431
|
+
cost_usd, session_id, user_id, feature, app_id, timestamp)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
418
433
|
).run(
|
|
419
434
|
entry.model,
|
|
420
435
|
entry.inputTokens,
|
|
@@ -426,6 +441,7 @@ var SqliteStorage = class {
|
|
|
426
441
|
entry.sessionId ?? null,
|
|
427
442
|
entry.userId ?? null,
|
|
428
443
|
entry.feature ?? null,
|
|
444
|
+
entry.appId ?? null,
|
|
429
445
|
entry.timestamp
|
|
430
446
|
);
|
|
431
447
|
}
|
|
@@ -442,6 +458,7 @@ var SqliteStorage = class {
|
|
|
442
458
|
...r.session_id != null && { sessionId: r.session_id },
|
|
443
459
|
...r.user_id != null && { userId: r.user_id },
|
|
444
460
|
...r.feature != null && { feature: r.feature },
|
|
461
|
+
...r.app_id != null && { appId: r.app_id },
|
|
445
462
|
timestamp: r.timestamp
|
|
446
463
|
}));
|
|
447
464
|
}
|
|
@@ -492,6 +509,40 @@ function calculateCost(inputTokens, outputTokens, price, cachedTokens = 0, cache
|
|
|
492
509
|
return regularInputCost + cachedReadCost + cacheCreationCost + outputCost;
|
|
493
510
|
}
|
|
494
511
|
|
|
512
|
+
// src/exporters/cloud.ts
|
|
513
|
+
var DEFAULT_ENDPOINT = "https://api.tokenwatch.dev/v1/ingest";
|
|
514
|
+
var CloudExporter = class {
|
|
515
|
+
constructor(apiKey, endpoint) {
|
|
516
|
+
this.apiKey = apiKey;
|
|
517
|
+
this.endpoint = endpoint ?? DEFAULT_ENDPOINT;
|
|
518
|
+
}
|
|
519
|
+
apiKey;
|
|
520
|
+
endpoint;
|
|
521
|
+
export(entry) {
|
|
522
|
+
fetch(this.endpoint, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
headers: {
|
|
525
|
+
"Content-Type": "application/json",
|
|
526
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
527
|
+
},
|
|
528
|
+
body: JSON.stringify({
|
|
529
|
+
model: entry.model,
|
|
530
|
+
inputTokens: entry.inputTokens,
|
|
531
|
+
outputTokens: entry.outputTokens,
|
|
532
|
+
reasoningTokens: entry.reasoningTokens ?? 0,
|
|
533
|
+
cachedTokens: entry.cachedTokens ?? 0,
|
|
534
|
+
cacheCreationTokens: entry.cacheCreationTokens ?? 0,
|
|
535
|
+
costUSD: entry.costUSD,
|
|
536
|
+
sessionId: entry.sessionId,
|
|
537
|
+
userId: entry.userId,
|
|
538
|
+
feature: entry.feature,
|
|
539
|
+
timestamp: entry.timestamp
|
|
540
|
+
})
|
|
541
|
+
}).catch(() => {
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
495
546
|
// src/core/suggestions.ts
|
|
496
547
|
var PROVIDER_PREFIXES = ["gpt-", "claude-", "gemini-", "deepseek-"];
|
|
497
548
|
function getProviderPrefix(model) {
|
|
@@ -527,7 +578,7 @@ function maybeSuggestCheaperModel(model, costUSD, inputTokens, outputTokens, lay
|
|
|
527
578
|
|
|
528
579
|
// prices.json
|
|
529
580
|
var prices_default = {
|
|
530
|
-
updated_at: "2026-
|
|
581
|
+
updated_at: "2026-06-09",
|
|
531
582
|
source: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
|
|
532
583
|
models: {
|
|
533
584
|
"gpt-4o": {
|
|
@@ -571,14 +622,12 @@ var prices_default = {
|
|
|
571
622
|
input: 3,
|
|
572
623
|
output: 15,
|
|
573
624
|
cachedInput: 0.3,
|
|
574
|
-
|
|
575
|
-
maxInputTokens: 1e6
|
|
625
|
+
maxInputTokens: 2e5
|
|
576
626
|
},
|
|
577
627
|
"claude-haiku-4-5": {
|
|
578
628
|
input: 1,
|
|
579
629
|
output: 5,
|
|
580
630
|
cachedInput: 0.1,
|
|
581
|
-
cacheCreationInput: 1.25,
|
|
582
631
|
maxInputTokens: 2e5
|
|
583
632
|
},
|
|
584
633
|
"gemini-2.5-pro": {
|
|
@@ -630,7 +679,6 @@ var prices_default = {
|
|
|
630
679
|
input: 3,
|
|
631
680
|
output: 15,
|
|
632
681
|
cachedInput: 0.3,
|
|
633
|
-
cacheCreationInput: 3.75,
|
|
634
682
|
maxInputTokens: 2e5
|
|
635
683
|
},
|
|
636
684
|
"gpt-oss-120b": {
|
|
@@ -1123,9 +1171,9 @@ var prices_default = {
|
|
|
1123
1171
|
maxInputTokens: 163840
|
|
1124
1172
|
},
|
|
1125
1173
|
"deepseek-r1": {
|
|
1126
|
-
input:
|
|
1127
|
-
output:
|
|
1128
|
-
maxInputTokens:
|
|
1174
|
+
input: 1.35,
|
|
1175
|
+
output: 5.4,
|
|
1176
|
+
maxInputTokens: 128e3
|
|
1129
1177
|
},
|
|
1130
1178
|
"deepseek-v3": {
|
|
1131
1179
|
input: 0.27,
|
|
@@ -1565,7 +1613,7 @@ var prices_default = {
|
|
|
1565
1613
|
"deepseek-r1-distill-llama-70b": {
|
|
1566
1614
|
input: 0.99,
|
|
1567
1615
|
output: 0.99,
|
|
1568
|
-
maxInputTokens:
|
|
1616
|
+
maxInputTokens: 32768
|
|
1569
1617
|
},
|
|
1570
1618
|
"deepseek-llama3.3-70b": {
|
|
1571
1619
|
input: 0.2,
|
|
@@ -1849,7 +1897,97 @@ var prices_default = {
|
|
|
1849
1897
|
input: 5,
|
|
1850
1898
|
output: 30,
|
|
1851
1899
|
cachedInput: 0.5,
|
|
1900
|
+
maxInputTokens: 105e4
|
|
1901
|
+
},
|
|
1902
|
+
"gpt-5.5-2026-04-23": {
|
|
1903
|
+
input: 5,
|
|
1904
|
+
output: 30,
|
|
1905
|
+
cachedInput: 0.5,
|
|
1906
|
+
maxInputTokens: 105e4
|
|
1907
|
+
},
|
|
1908
|
+
"gpt-5.5-pro": {
|
|
1909
|
+
input: 30,
|
|
1910
|
+
output: 180,
|
|
1911
|
+
cachedInput: 3,
|
|
1912
|
+
maxInputTokens: 105e4
|
|
1913
|
+
},
|
|
1914
|
+
"gpt-5.5-pro-2026-04-23": {
|
|
1915
|
+
input: 30,
|
|
1916
|
+
output: 180,
|
|
1917
|
+
cachedInput: 3,
|
|
1918
|
+
maxInputTokens: 105e4
|
|
1919
|
+
},
|
|
1920
|
+
"gpt-5.4-mini-2026-03-17": {
|
|
1921
|
+
input: 0.75,
|
|
1922
|
+
output: 4.5,
|
|
1923
|
+
cachedInput: 0.075,
|
|
1924
|
+
maxInputTokens: 272e3
|
|
1925
|
+
},
|
|
1926
|
+
"gpt-5.4-nano-2026-03-17": {
|
|
1927
|
+
input: 0.2,
|
|
1928
|
+
output: 1.25,
|
|
1929
|
+
cachedInput: 0.02,
|
|
1852
1930
|
maxInputTokens: 272e3
|
|
1931
|
+
},
|
|
1932
|
+
"gpt-image-2": {
|
|
1933
|
+
input: 5,
|
|
1934
|
+
output: 10,
|
|
1935
|
+
cachedInput: 1.25
|
|
1936
|
+
},
|
|
1937
|
+
"gpt-image-2-2026-04-21": {
|
|
1938
|
+
input: 5,
|
|
1939
|
+
output: 10,
|
|
1940
|
+
cachedInput: 1.25
|
|
1941
|
+
},
|
|
1942
|
+
"gpt-realtime-2": {
|
|
1943
|
+
input: 4,
|
|
1944
|
+
output: 16,
|
|
1945
|
+
cachedInput: 0.4,
|
|
1946
|
+
maxInputTokens: 32e3
|
|
1947
|
+
},
|
|
1948
|
+
"gemini-3.5-flash": {
|
|
1949
|
+
input: 1.5,
|
|
1950
|
+
output: 9,
|
|
1951
|
+
cachedInput: 0.15,
|
|
1952
|
+
maxInputTokens: 1048576
|
|
1953
|
+
},
|
|
1954
|
+
"gemini-3.1-flash-lite": {
|
|
1955
|
+
input: 0.25,
|
|
1956
|
+
output: 1.5,
|
|
1957
|
+
cachedInput: 0.025,
|
|
1958
|
+
maxInputTokens: 1048576
|
|
1959
|
+
},
|
|
1960
|
+
"claude-opus-4-8": {
|
|
1961
|
+
input: 5,
|
|
1962
|
+
output: 25,
|
|
1963
|
+
cachedInput: 0.5,
|
|
1964
|
+
cacheCreationInput: 6.25,
|
|
1965
|
+
maxInputTokens: 1e6
|
|
1966
|
+
},
|
|
1967
|
+
"claude-opus-4-8@default": {
|
|
1968
|
+
input: 5,
|
|
1969
|
+
output: 25,
|
|
1970
|
+
cachedInput: 0.5,
|
|
1971
|
+
cacheCreationInput: 6.25,
|
|
1972
|
+
maxInputTokens: 1e6
|
|
1973
|
+
},
|
|
1974
|
+
"claude-4-sonnet": {
|
|
1975
|
+
input: 3,
|
|
1976
|
+
output: 15,
|
|
1977
|
+
cachedInput: 0.3,
|
|
1978
|
+
maxInputTokens: 2e5
|
|
1979
|
+
},
|
|
1980
|
+
"claude-4-opus": {
|
|
1981
|
+
input: 5,
|
|
1982
|
+
output: 25,
|
|
1983
|
+
cachedInput: 0.5,
|
|
1984
|
+
maxInputTokens: 2e5
|
|
1985
|
+
},
|
|
1986
|
+
"claude-3-7-sonnet": {
|
|
1987
|
+
input: 3,
|
|
1988
|
+
output: 15,
|
|
1989
|
+
cachedInput: 0.3,
|
|
1990
|
+
maxInputTokens: 2e5
|
|
1853
1991
|
}
|
|
1854
1992
|
}
|
|
1855
1993
|
};
|
|
@@ -1889,7 +2027,10 @@ var TrackerConfigSchema = z.object({
|
|
|
1889
2027
|
windowHours: z.number().positive().optional().default(24),
|
|
1890
2028
|
mode: z.enum(["once", "always"]).optional().default("once")
|
|
1891
2029
|
}).optional(),
|
|
1892
|
-
exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional()
|
|
2030
|
+
exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional(),
|
|
2031
|
+
appId: z.string().optional(),
|
|
2032
|
+
cloudApiKey: z.string().optional(),
|
|
2033
|
+
cloudEndpoint: z.string().url().optional()
|
|
1893
2034
|
});
|
|
1894
2035
|
function createTracker(config = {}) {
|
|
1895
2036
|
const parsed = TrackerConfigSchema.safeParse(config);
|
|
@@ -1908,9 +2049,13 @@ ${issues}`);
|
|
|
1908
2049
|
budgets,
|
|
1909
2050
|
suggestions,
|
|
1910
2051
|
anomalyDetection,
|
|
1911
|
-
exporter
|
|
2052
|
+
exporter,
|
|
2053
|
+
appId,
|
|
2054
|
+
cloudApiKey,
|
|
2055
|
+
cloudEndpoint
|
|
1912
2056
|
} = parsed.data;
|
|
1913
2057
|
const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
|
|
2058
|
+
const cloudExporter = cloudApiKey ? new CloudExporter(cloudApiKey, cloudEndpoint) : null;
|
|
1914
2059
|
let remotePrices;
|
|
1915
2060
|
let pricesUpdatedAt = bundledUpdatedAt;
|
|
1916
2061
|
if (syncPrices) {
|
|
@@ -1963,13 +2108,17 @@ ${issues}`);
|
|
|
1963
2108
|
const full = {
|
|
1964
2109
|
...entry,
|
|
1965
2110
|
costUSD,
|
|
1966
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2111
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2112
|
+
...appId !== void 0 && entry.appId === void 0 && { appId }
|
|
1967
2113
|
};
|
|
1968
2114
|
storage.record(full);
|
|
1969
2115
|
if (exporter) {
|
|
1970
2116
|
Promise.resolve(exporter.export(full)).catch(() => {
|
|
1971
2117
|
});
|
|
1972
2118
|
}
|
|
2119
|
+
if (cloudExporter) {
|
|
2120
|
+
cloudExporter.export(full);
|
|
2121
|
+
}
|
|
1973
2122
|
maybeFireAlerts(full);
|
|
1974
2123
|
if (anomalyDetection) maybeDetectAnomaly(full);
|
|
1975
2124
|
if (suggestions) {
|
|
@@ -2050,6 +2199,7 @@ ${issues}`);
|
|
|
2050
2199
|
const bySession = {};
|
|
2051
2200
|
const byUser = {};
|
|
2052
2201
|
const byFeature = {};
|
|
2202
|
+
const byApp = {};
|
|
2053
2203
|
let totalInput = 0;
|
|
2054
2204
|
let totalOutput = 0;
|
|
2055
2205
|
let totalCost = 0;
|
|
@@ -2086,6 +2236,11 @@ ${issues}`);
|
|
|
2086
2236
|
f.costUSD += e.costUSD;
|
|
2087
2237
|
f.calls += 1;
|
|
2088
2238
|
}
|
|
2239
|
+
if (e.appId) {
|
|
2240
|
+
const a = byApp[e.appId] ??= { costUSD: 0, calls: 0 };
|
|
2241
|
+
a.costUSD += e.costUSD;
|
|
2242
|
+
a.calls += 1;
|
|
2243
|
+
}
|
|
2089
2244
|
}
|
|
2090
2245
|
if (options && entries.length > 0) {
|
|
2091
2246
|
periodFrom = entries[0]?.timestamp ?? periodFrom;
|
|
@@ -2097,6 +2252,7 @@ ${issues}`);
|
|
|
2097
2252
|
bySession,
|
|
2098
2253
|
byUser,
|
|
2099
2254
|
byFeature,
|
|
2255
|
+
byApp,
|
|
2100
2256
|
period: { from: periodFrom, to: lastTimestamp },
|
|
2101
2257
|
...pricesUpdatedAt ? { pricesUpdatedAt } : {}
|
|
2102
2258
|
};
|
|
@@ -2201,7 +2357,7 @@ ${issues}`);
|
|
|
2201
2357
|
}
|
|
2202
2358
|
async function exportCSV() {
|
|
2203
2359
|
const entries = await Promise.resolve(storage.getAll());
|
|
2204
|
-
const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature";
|
|
2360
|
+
const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature,appId";
|
|
2205
2361
|
const rows = entries.map(
|
|
2206
2362
|
(e) => [
|
|
2207
2363
|
csvEscape(e.timestamp),
|
|
@@ -2214,7 +2370,8 @@ ${issues}`);
|
|
|
2214
2370
|
e.costUSD.toFixed(8),
|
|
2215
2371
|
csvEscape(e.sessionId ?? ""),
|
|
2216
2372
|
csvEscape(e.userId ?? ""),
|
|
2217
|
-
csvEscape(e.feature ?? "")
|
|
2373
|
+
csvEscape(e.feature ?? ""),
|
|
2374
|
+
csvEscape(e.appId ?? "")
|
|
2218
2375
|
].join(",")
|
|
2219
2376
|
);
|
|
2220
2377
|
return [header, ...rows].join("\n");
|
|
@@ -2332,6 +2489,7 @@ async function getDashboardData(storage, filter) {
|
|
|
2332
2489
|
const bySession = {};
|
|
2333
2490
|
const byUser = {};
|
|
2334
2491
|
const byFeature = {};
|
|
2492
|
+
const byApp = {};
|
|
2335
2493
|
let totalInput = 0;
|
|
2336
2494
|
let totalOutput = 0;
|
|
2337
2495
|
let totalCost = 0;
|
|
@@ -2365,6 +2523,11 @@ async function getDashboardData(storage, filter) {
|
|
|
2365
2523
|
f.costUSD += e.costUSD;
|
|
2366
2524
|
f.calls += 1;
|
|
2367
2525
|
}
|
|
2526
|
+
if (e.appId) {
|
|
2527
|
+
const a = byApp[e.appId] ??= { costUSD: 0, calls: 0 };
|
|
2528
|
+
a.costUSD += e.costUSD;
|
|
2529
|
+
a.calls += 1;
|
|
2530
|
+
}
|
|
2368
2531
|
}
|
|
2369
2532
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2370
2533
|
const periodFrom = entries[0]?.timestamp ?? now;
|
|
@@ -2376,6 +2539,7 @@ async function getDashboardData(storage, filter) {
|
|
|
2376
2539
|
bySession,
|
|
2377
2540
|
byUser,
|
|
2378
2541
|
byFeature,
|
|
2542
|
+
byApp,
|
|
2379
2543
|
period: { from: periodFrom, to: periodTo }
|
|
2380
2544
|
};
|
|
2381
2545
|
const forecastWindowMs = 24 * 60 * 60 * 1e3;
|
|
@@ -2422,555 +2586,1495 @@ async function getDashboardData(storage, filter) {
|
|
|
2422
2586
|
}
|
|
2423
2587
|
|
|
2424
2588
|
// src/dashboard/html.ts
|
|
2425
|
-
function getHtml(
|
|
2426
|
-
void port;
|
|
2589
|
+
function getHtml(_port) {
|
|
2427
2590
|
return `<!DOCTYPE html>
|
|
2428
2591
|
<html lang="en">
|
|
2429
2592
|
<head>
|
|
2430
2593
|
<meta charset="UTF-8" />
|
|
2431
2594
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2432
|
-
<title>tokenwatch
|
|
2433
|
-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
2595
|
+
<title>tokenwatch \u2014 Overview</title>
|
|
2434
2596
|
<style>
|
|
2435
|
-
|
|
2597
|
+
:root{
|
|
2598
|
+
--bg:#0d1117; --surface:#161b22; --surface2:#1c2128;
|
|
2599
|
+
--border:#30363d; --border-muted:#21262d;
|
|
2600
|
+
--text:#e6edf3; --muted:#7d8590; --dim:#484f58;
|
|
2601
|
+
--accent:#58a6ff; --green:#3fb950; --yellow:#d29922; --red:#f85149;
|
|
2602
|
+
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
|
2603
|
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
|
2604
|
+
}
|
|
2605
|
+
*{box-sizing:border-box}
|
|
2606
|
+
html,body{margin:0}
|
|
2607
|
+
body{background:var(--bg);color:var(--text);font-family:var(--sans);
|
|
2608
|
+
font-size:13px;line-height:1.45;-webkit-font-smoothing:antialiased;
|
|
2609
|
+
font-variant-numeric:tabular-nums}
|
|
2610
|
+
h1,h2,h3{margin:0;font-weight:600}
|
|
2611
|
+
a{color:inherit;text-decoration:none}
|
|
2612
|
+
kbd{font-family:var(--sans);font-size:10px;color:var(--muted);
|
|
2613
|
+
background:#22272e;border:1px solid var(--border);border-radius:4px;
|
|
2614
|
+
padding:1px 5px;line-height:1.4}
|
|
2615
|
+
.tw-root{max-width:1440px;margin:0 auto;min-height:100vh;
|
|
2616
|
+
border-left:1px solid var(--border-muted);border-right:1px solid var(--border-muted)}
|
|
2617
|
+
.num,.tw-kpi-value,.tw-cost,.tw-num-cell,.tw-fc-value{font-variant-numeric:tabular-nums}
|
|
2618
|
+
.tw-header{position:sticky;top:0;z-index:40;height:54px;display:flex;
|
|
2619
|
+
align-items:center;justify-content:space-between;gap:16px;padding:0 18px;
|
|
2620
|
+
background:rgba(13,17,23,.86);backdrop-filter:blur(10px);
|
|
2621
|
+
border-bottom:1px solid var(--border)}
|
|
2622
|
+
.tw-hgroup{display:flex;align-items:center;gap:14px}
|
|
2623
|
+
.tw-logo{font-size:15px;font-weight:700;letter-spacing:-.01em}
|
|
2624
|
+
.tw-logo span{color:var(--accent)}
|
|
2625
|
+
.tw-ws{position:relative;display:flex;align-items:center;gap:7px;height:30px;
|
|
2626
|
+
padding:0 9px;background:var(--surface);border:1px solid var(--border);
|
|
2627
|
+
border-radius:7px;color:var(--text);font-size:12.5px;font-weight:500;cursor:pointer}
|
|
2628
|
+
.tw-ws:hover{border-color:#444c56}
|
|
2629
|
+
.tw-ws-dot{width:7px;height:7px;border-radius:50%;background:var(--green);
|
|
2630
|
+
box-shadow:0 0 0 3px rgba(63,185,80,.15)}
|
|
2631
|
+
.tw-ws-env{font-size:10px;color:var(--muted);background:#22272e;
|
|
2632
|
+
border-radius:4px;padding:1px 5px;text-transform:uppercase;letter-spacing:.03em}
|
|
2633
|
+
.tw-ws-menu{position:absolute;top:36px;left:0;width:230px;background:var(--surface2);
|
|
2634
|
+
border:1px solid var(--border);border-radius:9px;padding:5px;z-index:50;
|
|
2635
|
+
box-shadow:0 12px 32px rgba(0,0,0,.5)}
|
|
2636
|
+
.tw-ws-item{padding:7px 9px;border-radius:6px;font-size:12.5px;cursor:pointer;color:var(--text)}
|
|
2637
|
+
.tw-ws-item:hover{background:#22272e}
|
|
2638
|
+
.tw-ws-item.on{color:var(--accent)}
|
|
2639
|
+
.tw-ws-item.muted{color:var(--muted)}
|
|
2640
|
+
.tw-ws-sep{height:1px;background:var(--border);margin:5px 0}
|
|
2641
|
+
.tw-nav{display:flex;gap:2px;margin-left:6px}
|
|
2642
|
+
.tw-nav a{padding:6px 10px;border-radius:6px;font-size:13px;color:var(--muted);font-weight:500}
|
|
2643
|
+
.tw-nav a:hover{color:var(--text);background:var(--surface)}
|
|
2644
|
+
.tw-nav a.on{color:var(--text);background:var(--surface);box-shadow:inset 0 -2px 0 var(--accent)}
|
|
2645
|
+
.tw-search{display:flex;align-items:center;gap:8px;height:30px;padding:0 9px;
|
|
2646
|
+
width:200px;background:var(--surface);border:1px solid var(--border);
|
|
2647
|
+
border-radius:7px;color:var(--muted);font-size:12.5px;cursor:text}
|
|
2648
|
+
.tw-search:hover{border-color:#444c56}
|
|
2649
|
+
.tw-search span{flex:1;text-align:left}
|
|
2650
|
+
.tw-btn-2{display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;
|
|
2651
|
+
background:var(--surface);border:1px solid var(--border);border-radius:7px;
|
|
2652
|
+
color:var(--text);font-size:12.5px;font-weight:500;cursor:pointer}
|
|
2653
|
+
.tw-btn-2:hover{border-color:#444c56;background:#1b2128}
|
|
2654
|
+
.tw-user{display:flex;align-items:center;gap:8px;margin-left:2px}
|
|
2655
|
+
.tw-plan{font-size:10px;font-weight:600;color:var(--accent);
|
|
2656
|
+
border:1px solid rgba(88,166,255,.4);background:rgba(88,166,255,.1);
|
|
2657
|
+
border-radius:5px;padding:2px 6px;text-transform:uppercase;letter-spacing:.04em}
|
|
2658
|
+
.tw-avatar{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;
|
|
2659
|
+
font-size:11px;font-weight:600;color:#fff;
|
|
2660
|
+
background:linear-gradient(135deg,#bc8cff,#58a6ff)}
|
|
2661
|
+
.tw-main{padding:16px 18px 64px;display:flex;flex-direction:column;gap:14px}
|
|
2662
|
+
.tw-budget{background:var(--surface);border:1px solid var(--border);
|
|
2663
|
+
border-radius:10px;padding:13px 16px}
|
|
2664
|
+
.tw-budget-top{display:flex;align-items:center;gap:16px;margin-bottom:10px}
|
|
2665
|
+
.tw-budget-label{display:flex;align-items:baseline;gap:10px}
|
|
2666
|
+
.tw-bud-strong{font-weight:600;font-size:13px}
|
|
2667
|
+
.tw-bud-nums{color:var(--muted);font-size:12.5px}
|
|
2668
|
+
.tw-bud-nums b{color:var(--text)}
|
|
2669
|
+
.tw-bud-alert{display:flex;align-items:center;gap:6px;font-size:12px;
|
|
2670
|
+
padding:3px 9px;border-radius:6px;color:var(--green);
|
|
2671
|
+
background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25)}
|
|
2672
|
+
.tw-bud-alert b{color:var(--text)}
|
|
2673
|
+
.tw-budget-days{margin-left:auto;color:var(--muted);font-size:12px}
|
|
2674
|
+
.tw-bar{position:relative;height:9px;background:var(--border-muted);
|
|
2675
|
+
border-radius:5px;overflow:visible}
|
|
2676
|
+
.tw-bar-fill{height:100%;border-radius:5px;transition:width .5s}
|
|
2677
|
+
.tw-bar-proj{position:absolute;top:0;height:100%;
|
|
2678
|
+
background:repeating-linear-gradient(45deg,rgba(125,133,144,.35),rgba(125,133,144,.35) 4px,transparent 4px,transparent 8px);
|
|
2679
|
+
border-radius:0 5px 5px 0}
|
|
2680
|
+
.tw-bar-marker{position:absolute;top:-3px;width:2px;height:15px;background:var(--text);border-radius:2px}
|
|
2681
|
+
.tw-bar-marker::after{content:attr(data-tip);position:absolute;top:-18px;left:50%;
|
|
2682
|
+
transform:translateX(-50%);font-size:9.5px;color:var(--muted);white-space:nowrap}
|
|
2683
|
+
.tw-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
|
|
2684
|
+
.tw-kpi{background:var(--surface);border:1px solid var(--border);border-radius:10px;
|
|
2685
|
+
padding:12px 14px;display:flex;flex-direction:column;gap:7px;min-width:0}
|
|
2686
|
+
.tw-kpi-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
2687
|
+
.tw-kpi-main{display:flex;align-items:flex-end;justify-content:space-between;gap:8px}
|
|
2688
|
+
.tw-kpi-value{font-size:21px;font-weight:600;letter-spacing:-.01em;
|
|
2689
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2690
|
+
.tw-kpi-foot{display:flex;align-items:center;gap:8px;font-size:11.5px;min-height:15px}
|
|
2691
|
+
.tw-delta{font-weight:600}
|
|
2692
|
+
.tw-kpi-sub{color:var(--muted)}
|
|
2693
|
+
.tw-timefilter{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 2px}
|
|
2694
|
+
.tw-tabs{display:flex;gap:2px;background:var(--surface);border:1px solid var(--border);
|
|
2695
|
+
border-radius:8px;padding:3px}
|
|
2696
|
+
.tw-tab{height:26px;padding:0 13px;border:0;background:transparent;color:var(--muted);
|
|
2697
|
+
font-size:12.5px;font-weight:600;font-family:var(--sans);border-radius:6px;cursor:pointer}
|
|
2698
|
+
.tw-tab:hover{color:var(--text)}
|
|
2699
|
+
.tw-tab.on{background:var(--accent);color:#0d1117}
|
|
2700
|
+
.tw-compare{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12.5px;cursor:pointer}
|
|
2701
|
+
.tw-toggle-sm{position:relative;width:30px;height:17px;border:0;border-radius:999px;
|
|
2702
|
+
background:#30363d;cursor:pointer;padding:0;transition:background .15s}
|
|
2703
|
+
.tw-toggle-sm[data-on="1"]{background:var(--accent)}
|
|
2704
|
+
.tw-toggle-sm i{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;
|
|
2705
|
+
background:#fff;transition:transform .15s}
|
|
2706
|
+
.tw-toggle-sm[data-on="1"] i{transform:translateX(13px)}
|
|
2707
|
+
.tw-card,.tw-section{background:var(--surface);border:1px solid var(--border);border-radius:10px}
|
|
2708
|
+
.tw-section{padding:0}
|
|
2709
|
+
.tw-card{padding:14px 16px}
|
|
2710
|
+
.tw-sec-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
|
2711
|
+
padding:13px 16px 11px}
|
|
2712
|
+
.tw-card .tw-sec-head{padding:0 0 10px}
|
|
2713
|
+
.tw-sec-head h3{font-size:13.5px}
|
|
2714
|
+
.tw-sec-sub{color:var(--muted);font-size:11.5px;font-weight:400}
|
|
2715
|
+
.tw-charts{display:grid;grid-template-columns:2fr 1fr;gap:14px}
|
|
2716
|
+
.tw-chart-main,.tw-chart-side{min-width:0}
|
|
2717
|
+
.tw-chart-legend{display:flex;align-items:center;gap:12px;font-size:11.5px;color:var(--muted)}
|
|
2718
|
+
.tw-chart-legend span{display:flex;align-items:center;gap:5px}
|
|
2719
|
+
.tw-chart-legend i{width:14px;height:3px;border-radius:2px;background:var(--accent)}
|
|
2720
|
+
.tw-chart-legend i.dash{background:repeating-linear-gradient(90deg,#6e7681,#6e7681 3px,transparent 3px,transparent 6px)}
|
|
2721
|
+
.tw-chart-legend .muted i{background:#6e7681}
|
|
2722
|
+
.tw-draw-line{stroke-dasharray:2600;stroke-dashoffset:2600;animation:tw-draw 1.1s cubic-bezier(.4,0,.2,1) forwards}
|
|
2723
|
+
.tw-draw-area{opacity:0;animation:tw-fade .9s ease .25s forwards}
|
|
2724
|
+
@keyframes tw-draw{to{stroke-dashoffset:0}}
|
|
2725
|
+
@keyframes tw-fade{to{opacity:1}}
|
|
2726
|
+
.tw-doughnut-wrap{display:grid;place-items:center;padding:6px 0 10px}
|
|
2727
|
+
.tw-legend{display:flex;flex-direction:column;gap:1px}
|
|
2728
|
+
.tw-legend-row{display:flex;align-items:center;gap:8px;padding:5px 7px;border-radius:6px;cursor:pointer}
|
|
2729
|
+
.tw-legend-row:hover,.tw-legend-row.on{background:#1b2128}
|
|
2730
|
+
.tw-legend-name{flex:1;font-size:12px;font-family:var(--mono);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2731
|
+
.tw-legend-val{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2732
|
+
.tw-legend-pct{font-size:11px;color:var(--dim);width:30px;text-align:right;font-variant-numeric:tabular-nums}
|
|
2733
|
+
.tw-model-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
|
|
2734
|
+
.tw-model-dot.lg{width:11px;height:11px;border-radius:3px}
|
|
2735
|
+
.tw-table-wrap{overflow-x:auto}
|
|
2736
|
+
.tw-table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
2737
|
+
.tw-table thead th{position:sticky;top:0;text-align:left;padding:8px 16px;
|
|
2738
|
+
font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;
|
|
2739
|
+
letter-spacing:.04em;border-bottom:1px solid var(--border);user-select:none;
|
|
2740
|
+
background:var(--surface)}
|
|
2741
|
+
.tw-th-inner{display:flex;align-items:center;gap:4px}
|
|
2742
|
+
.tw-th-inner:hover{color:var(--text)}
|
|
2743
|
+
.tw-sort{color:var(--accent);font-size:10px}
|
|
2744
|
+
.tw-row{border-bottom:1px solid var(--border-muted);cursor:pointer;transition:background .1s}
|
|
2745
|
+
.tw-row:last-child{border-bottom:0}
|
|
2746
|
+
.tw-row td{padding:10px 16px;vertical-align:middle}
|
|
2747
|
+
.tw-table.compact .tw-row td{padding:6px 16px}
|
|
2748
|
+
.tw-table.compact thead th{padding:6px 16px}
|
|
2749
|
+
.tw-row:hover{background:#1b2128}
|
|
2750
|
+
.tw-row.example{background:rgba(88,166,255,.07);box-shadow:inset 2px 0 0 var(--accent)}
|
|
2751
|
+
.tw-row.example:hover{background:rgba(88,166,255,.11)}
|
|
2752
|
+
.tw-model-cell{display:flex;align-items:center;gap:9px}
|
|
2753
|
+
.tw-model-name{font-family:var(--mono);font-size:12.5px;color:var(--text)}
|
|
2754
|
+
.tw-model-prov{font-size:10px;color:var(--muted);background:#22272e;border-radius:4px;padding:1px 5px}
|
|
2755
|
+
.tw-num-cell{text-align:right;font-variant-numeric:tabular-nums;color:var(--text);white-space:nowrap}
|
|
2756
|
+
.tw-cost{font-weight:600}
|
|
2757
|
+
.tw-row-actions{display:flex;gap:6px;justify-content:flex-end}
|
|
2758
|
+
.tw-row-actions button{height:24px;padding:0 9px;border:1px solid var(--border);
|
|
2759
|
+
background:var(--surface2);color:var(--text);border-radius:6px;font-size:11px;
|
|
2760
|
+
font-weight:500;cursor:pointer;font-family:var(--sans)}
|
|
2761
|
+
.tw-row-actions button:hover{border-color:var(--accent);color:var(--accent)}
|
|
2762
|
+
.tw-forecast{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:0 16px 16px}
|
|
2763
|
+
.tw-fc{background:var(--surface2);border:1px solid var(--border);border-radius:9px;
|
|
2764
|
+
padding:12px 14px;display:flex;flex-direction:column;gap:6px}
|
|
2765
|
+
.tw-fc.flag{border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.07)}
|
|
2766
|
+
.tw-fc-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}
|
|
2767
|
+
.tw-fc-value{font-size:20px;font-weight:600;letter-spacing:-.01em}
|
|
2768
|
+
.tw-fc-sub{font-size:11.5px;color:var(--muted)}
|
|
2769
|
+
.tw-act-title{display:flex;align-items:center;gap:9px}
|
|
2770
|
+
.tw-collapse{border:0;background:transparent;color:var(--muted);cursor:pointer;
|
|
2771
|
+
display:grid;place-items:center;padding:2px;transition:transform .15s}
|
|
2772
|
+
.tw-live-dot{width:8px;height:8px;border-radius:50%;background:var(--dim)}
|
|
2773
|
+
.tw-live-dot.on{background:var(--green);animation:tw-pulse 1.6s ease-in-out infinite}
|
|
2774
|
+
@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)}}
|
|
2775
|
+
.tw-act-controls{display:flex;align-items:center;gap:10px}
|
|
2776
|
+
.tw-seg-sm{display:flex;gap:1px;background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:2px}
|
|
2777
|
+
.tw-seg-sm button{height:22px;padding:0 9px;border:0;background:transparent;color:var(--muted);
|
|
2778
|
+
font-size:11px;font-weight:600;border-radius:5px;cursor:pointer;font-family:var(--sans)}
|
|
2779
|
+
.tw-seg-sm button.on{background:var(--border);color:var(--text)}
|
|
2780
|
+
.tw-act-pause{height:26px;padding:0 10px;border:1px solid var(--border);background:var(--surface2);
|
|
2781
|
+
color:var(--muted);border-radius:6px;font-size:11.5px;cursor:pointer;font-family:var(--sans)}
|
|
2782
|
+
.tw-act-pause:hover{color:var(--text);border-color:#444c56}
|
|
2783
|
+
.tw-feed{padding:0 6px 8px}
|
|
2784
|
+
.tw-feed-head,.tw-feed-row{display:grid;grid-template-columns:72px 1.4fr 1fr 1fr 96px;gap:12px;align-items:center}
|
|
2785
|
+
.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)}
|
|
2786
|
+
.tw-feed-body{display:flex;flex-direction:column}
|
|
2787
|
+
.tw-feed-row{padding:7px 10px;font-size:12px;border-bottom:1px solid var(--border-muted)}
|
|
2788
|
+
.tw-feed-row:last-child{border-bottom:0}
|
|
2789
|
+
.tw-feed-row.fresh{animation:tw-flash 1.4s ease}
|
|
2790
|
+
.tw-feed-row.err{background:rgba(248,81,73,.05)}
|
|
2791
|
+
@keyframes tw-flash{0%{background:rgba(88,166,255,.16)}100%{background:transparent}}
|
|
2792
|
+
.tw-feed-time{color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2793
|
+
.tw-feed-model{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11.5px;color:var(--text);overflow:hidden}
|
|
2794
|
+
.tw-feed-sess{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
|
2795
|
+
.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}
|
|
2796
|
+
.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}
|
|
2797
|
+
.tw-feed-flag.slow{color:var(--yellow);border-color:rgba(210,153,34,.4)}
|
|
2798
|
+
.tw-feat{font-size:10.5px;font-family:var(--mono);border:1px solid;border-radius:5px;padding:1px 6px;white-space:nowrap}
|
|
2799
|
+
.tw-feat.sm{font-size:10px;padding:0 5px}
|
|
2800
|
+
.tw-scrim{position:fixed;inset:0;background:rgba(1,4,9,.6);opacity:0;pointer-events:none;
|
|
2801
|
+
transition:opacity .25s;z-index:60}
|
|
2802
|
+
.tw-scrim.open{opacity:1;pointer-events:auto}
|
|
2803
|
+
.tw-slideover{position:fixed;top:0;right:0;height:100vh;width:400px;background:var(--surface);
|
|
2804
|
+
border-left:1px solid var(--border);transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
|
|
2805
|
+
z-index:61;box-shadow:-16px 0 40px rgba(0,0,0,.4);overflow-y:auto}
|
|
2806
|
+
.tw-slideover.open{transform:translateX(0)}
|
|
2807
|
+
.tw-so-inner{padding:18px}
|
|
2808
|
+
.tw-so-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:16px}
|
|
2809
|
+
.tw-so-title{display:flex;align-items:center;gap:10px}
|
|
2810
|
+
.tw-so-name{font-family:var(--mono);font-size:15px;font-weight:600}
|
|
2811
|
+
.tw-so-prov{font-size:11.5px;color:var(--muted);margin-top:2px}
|
|
2812
|
+
.tw-so-close{width:28px;height:28px;border:1px solid var(--border);background:var(--surface2);
|
|
2813
|
+
color:var(--muted);border-radius:7px;cursor:pointer;font-size:13px}
|
|
2814
|
+
.tw-so-close:hover{color:var(--text);border-color:#444c56}
|
|
2815
|
+
.tw-so-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px}
|
|
2816
|
+
.tw-so-stat{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px}
|
|
2817
|
+
.tw-so-stat-l{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px}
|
|
2818
|
+
.tw-so-stat-v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
2819
|
+
.tw-so-metarow{display:flex;justify-content:space-between;padding:11px 12px;background:var(--surface2);
|
|
2820
|
+
border:1px solid var(--border);border-radius:8px;margin-bottom:16px}
|
|
2821
|
+
.tw-so-metarow>div{display:flex;flex-direction:column;gap:3px}
|
|
2822
|
+
.tw-so-meta-l{font-size:10.5px;color:var(--muted)}
|
|
2823
|
+
.tw-so-meta-v{font-size:12.5px;font-weight:500;font-variant-numeric:tabular-nums}
|
|
2824
|
+
.tw-so-section-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
|
|
2825
|
+
.tw-so-calls{display:flex;flex-direction:column;gap:7px;margin-bottom:14px}
|
|
2826
|
+
.tw-so-call{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:9px 11px}
|
|
2827
|
+
.tw-so-call-top{display:flex;align-items:center;gap:9px;margin-bottom:5px}
|
|
2828
|
+
.tw-so-call-time{font-size:11px;color:var(--muted);flex:1}
|
|
2829
|
+
.tw-so-call-cost{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
2830
|
+
.tw-so-call-bot{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
2831
|
+
.tw-so-viewall{width:100%;height:36px;border:1px solid var(--border);background:var(--surface2);
|
|
2832
|
+
color:var(--accent);border-radius:8px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:var(--sans);
|
|
2833
|
+
display:flex;align-items:center;justify-content:center;gap:6px}
|
|
2834
|
+
.tw-so-viewall:hover{border-color:var(--accent);background:rgba(88,166,255,.08)}
|
|
2835
|
+
.tw-cmdk-scrim{position:fixed;inset:0;background:rgba(1,4,9,.55);z-index:80;
|
|
2836
|
+
display:flex;justify-content:center;align-items:flex-start;padding-top:14vh;
|
|
2837
|
+
animation:tw-fade .15s ease}
|
|
2838
|
+
.tw-cmdk{width:560px;max-width:92vw;background:var(--surface2);border:1px solid var(--border);
|
|
2839
|
+
border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden}
|
|
2840
|
+
.tw-cmdk-input{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--border)}
|
|
2841
|
+
.tw-cmdk-input input{flex:1;background:transparent;border:0;outline:none;color:var(--text);
|
|
2842
|
+
font-size:14px;font-family:var(--sans)}
|
|
2843
|
+
.tw-cmdk-list{max-height:360px;overflow-y:auto;padding:6px}
|
|
2844
|
+
.tw-cmdk-item{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:7px;cursor:pointer}
|
|
2845
|
+
.tw-cmdk-item:hover{background:#22272e}
|
|
2846
|
+
.tw-cmdk-g{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);width:62px}
|
|
2847
|
+
.tw-cmdk-l{flex:1;font-size:13px}
|
|
2848
|
+
.tw-cmdk-empty{padding:18px;text-align:center;color:var(--muted);font-size:12.5px}
|
|
2849
|
+
.tw-skel-wrap{display:flex;flex-direction:column;gap:14px}
|
|
2850
|
+
.tw-skel{background:linear-gradient(90deg,var(--surface) 25%,#1b2128 50%,var(--surface) 75%);
|
|
2851
|
+
background-size:200% 100%;border:1px solid var(--border);border-radius:10px;
|
|
2852
|
+
animation:tw-shim 1.4s linear infinite}
|
|
2853
|
+
@keyframes tw-shim{to{background-position:-200% 0}}
|
|
2854
|
+
.tw-empty{display:flex;flex-direction:column;align-items:center;gap:12px;
|
|
2855
|
+
padding:80px 20px;text-align:center}
|
|
2856
|
+
.tw-empty-art{width:96px;height:96px;display:grid;place-items:center;
|
|
2857
|
+
background:var(--surface);border:1px solid var(--border);border-radius:20px}
|
|
2858
|
+
.tw-empty h2{font-size:18px}
|
|
2859
|
+
.tw-empty p{max-width:420px;color:var(--muted);font-size:13px;margin:0}
|
|
2860
|
+
.tw-empty-actions{display:flex;gap:10px;margin-top:6px}
|
|
2861
|
+
.tw-btn-primary{display:flex;align-items:center;gap:6px;height:34px;padding:0 14px;
|
|
2862
|
+
background:var(--accent);color:#0d1117;border:0;border-radius:8px;font-size:13px;
|
|
2863
|
+
font-weight:600;cursor:pointer;font-family:var(--sans)}
|
|
2864
|
+
.tw-btn-primary:hover{filter:brightness(1.08)}
|
|
2865
|
+
.tw-empty-snippet{margin-top:12px;font-family:var(--mono);font-size:12px;color:var(--muted);
|
|
2866
|
+
background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px}
|
|
2867
|
+
.tw-snip-c{color:#ff7b72}.tw-snip-s{color:var(--green)}
|
|
2868
|
+
.tw-so-call-lat{font-variant-numeric:tabular-nums}
|
|
2869
|
+
@media(prefers-reduced-motion:reduce){*{animation-duration:.001s!important}}
|
|
2436
2870
|
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
}
|
|
2871
|
+
/* tweaks panel */
|
|
2872
|
+
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
|
2873
|
+
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
|
2874
|
+
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
|
2875
|
+
background:rgba(250,249,247,.78);color:#29261b;
|
|
2876
|
+
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
|
2877
|
+
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
|
2878
|
+
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
|
2879
|
+
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
|
2880
|
+
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
|
2881
|
+
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
|
2882
|
+
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
|
2883
|
+
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
|
2884
|
+
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
|
2885
|
+
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
|
2886
|
+
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
|
2887
|
+
overflow-y:auto;overflow-x:hidden;min-height:0;
|
|
2888
|
+
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
|
2889
|
+
.twk-body::-webkit-scrollbar{width:8px}
|
|
2890
|
+
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
|
2891
|
+
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
|
2892
|
+
border:2px solid transparent;background-clip:content-box}
|
|
2893
|
+
.twk-row{display:flex;flex-direction:column;gap:5px}
|
|
2894
|
+
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
|
2895
|
+
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;color:rgba(41,38,27,.72)}
|
|
2896
|
+
.twk-lbl>span:first-child{font-weight:500}
|
|
2897
|
+
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
|
2898
|
+
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
|
2899
|
+
color:rgba(41,38,27,.45);padding:10px 0 0}
|
|
2900
|
+
.twk-sect:first-child{padding-top:0}
|
|
2901
|
+
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
|
2902
|
+
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
|
2903
|
+
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
|
2904
|
+
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
|
2905
|
+
select.twk-field{padding-right:22px}
|
|
2906
|
+
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
|
2907
|
+
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
|
2908
|
+
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
|
2909
|
+
width:14px;height:14px;border-radius:50%;background:#fff;
|
|
2910
|
+
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
|
2911
|
+
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
|
2912
|
+
background:rgba(0,0,0,.06);user-select:none}
|
|
2913
|
+
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
|
2914
|
+
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
|
2915
|
+
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
|
2916
|
+
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
|
2917
|
+
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
|
2918
|
+
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2}
|
|
2919
|
+
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
|
2920
|
+
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
|
2921
|
+
.twk-toggle[data-on="1"]{background:#34c759}
|
|
2922
|
+
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
|
2923
|
+
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
|
2924
|
+
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
|
2925
|
+
.twk-chips{display:flex;gap:6px}
|
|
2926
|
+
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
|
2927
|
+
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
|
2928
|
+
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);transition:transform .12s}
|
|
2929
|
+
.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)}
|
|
2930
|
+
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;display:flex;flex-direction:column}
|
|
2931
|
+
.twk-chip>span>i{flex:1}
|
|
2932
|
+
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px}
|
|
2933
|
+
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
|
2934
|
+
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;background:transparent}
|
|
2935
|
+
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
|
2936
|
+
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
|
2937
|
+
</style>
|
|
2938
|
+
</head>
|
|
2939
|
+
<body>
|
|
2940
|
+
<div id="root"></div>
|
|
2941
|
+
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin="anonymous"></script>
|
|
2942
|
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin="anonymous"></script>
|
|
2943
|
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
|
|
2449
2944
|
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2945
|
+
<script type="text/babel">
|
|
2946
|
+
// tweaks-panel \u2014 without runtime style injection (CSS is in the main style block)
|
|
2947
|
+
function useTweaks(defaults) {
|
|
2948
|
+
const [values, setValues] = React.useState(defaults);
|
|
2949
|
+
const setTweak = React.useCallback((keyOrEdits, val) => {
|
|
2950
|
+
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
|
2951
|
+
? keyOrEdits : { [keyOrEdits]: val };
|
|
2952
|
+
setValues((prev) => ({ ...prev, ...edits }));
|
|
2953
|
+
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
|
2954
|
+
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
|
2955
|
+
}, []);
|
|
2956
|
+
return [values, setTweak];
|
|
2457
2957
|
}
|
|
2458
2958
|
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
.
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
.
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2959
|
+
function TweaksPanel({ title = 'Tweaks', children }) {
|
|
2960
|
+
const [open, setOpen] = React.useState(false);
|
|
2961
|
+
const dragRef = React.useRef(null);
|
|
2962
|
+
const offsetRef = React.useRef({ x: 16, y: 16 });
|
|
2963
|
+
const PAD = 16;
|
|
2964
|
+
const clampToViewport = React.useCallback(() => {
|
|
2965
|
+
const panel = dragRef.current; if (!panel) return;
|
|
2966
|
+
const w = panel.offsetWidth, h = panel.offsetHeight;
|
|
2967
|
+
offsetRef.current = {
|
|
2968
|
+
x: Math.min(Math.max(PAD, window.innerWidth - w - PAD), Math.max(PAD, offsetRef.current.x)),
|
|
2969
|
+
y: Math.min(Math.max(PAD, window.innerHeight - h - PAD), Math.max(PAD, offsetRef.current.y)),
|
|
2970
|
+
};
|
|
2971
|
+
panel.style.right = offsetRef.current.x + 'px';
|
|
2972
|
+
panel.style.bottom = offsetRef.current.y + 'px';
|
|
2973
|
+
}, []);
|
|
2974
|
+
React.useEffect(() => {
|
|
2975
|
+
if (!open) return;
|
|
2976
|
+
clampToViewport();
|
|
2977
|
+
const ro = new ResizeObserver(clampToViewport);
|
|
2978
|
+
ro.observe(document.documentElement);
|
|
2979
|
+
return () => ro.disconnect();
|
|
2980
|
+
}, [open, clampToViewport]);
|
|
2981
|
+
React.useEffect(() => {
|
|
2982
|
+
const onMsg = (e) => {
|
|
2983
|
+
const t = e && e.data && e.data.type;
|
|
2984
|
+
if (t === '__activate_edit_mode') setOpen(true);
|
|
2985
|
+
else if (t === '__deactivate_edit_mode') setOpen(false);
|
|
2986
|
+
};
|
|
2987
|
+
window.addEventListener('message', onMsg);
|
|
2988
|
+
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
|
2989
|
+
return () => window.removeEventListener('message', onMsg);
|
|
2990
|
+
}, []);
|
|
2991
|
+
const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); };
|
|
2992
|
+
const onDragStart = (e) => {
|
|
2993
|
+
const panel = dragRef.current; if (!panel) return;
|
|
2994
|
+
const r = panel.getBoundingClientRect();
|
|
2995
|
+
const sx = e.clientX, sy = e.clientY;
|
|
2996
|
+
const startRight = window.innerWidth - r.right, startBottom = window.innerHeight - r.bottom;
|
|
2997
|
+
const move = (ev) => { offsetRef.current = { x: startRight - (ev.clientX - sx), y: startBottom - (ev.clientY - sy) }; clampToViewport(); };
|
|
2998
|
+
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
|
2999
|
+
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
|
|
3000
|
+
};
|
|
3001
|
+
if (!open) return null;
|
|
3002
|
+
return (
|
|
3003
|
+
<div ref={dragRef} className="twk-panel" style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
|
3004
|
+
<div className="twk-hd" onMouseDown={onDragStart}>
|
|
3005
|
+
<b>{title}</b>
|
|
3006
|
+
<button className="twk-x" onMouseDown={(e) => e.stopPropagation()} onClick={dismiss}>✕</button>
|
|
3007
|
+
</div>
|
|
3008
|
+
<div className="twk-body">{children}</div>
|
|
3009
|
+
</div>
|
|
3010
|
+
);
|
|
2487
3011
|
}
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
border-radius: var(--r);
|
|
2497
|
-
padding: 4px;
|
|
2498
|
-
width: fit-content;
|
|
2499
|
-
}
|
|
2500
|
-
.tab {
|
|
2501
|
-
padding: 6px 14px;
|
|
2502
|
-
border-radius: calc(var(--r) - 2px);
|
|
2503
|
-
border: none;
|
|
2504
|
-
background: transparent;
|
|
2505
|
-
color: var(--muted);
|
|
2506
|
-
font-size: 13px;
|
|
2507
|
-
font-weight: 500;
|
|
2508
|
-
cursor: pointer;
|
|
2509
|
-
transition: background 0.15s, color 0.15s;
|
|
2510
|
-
}
|
|
2511
|
-
.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
|
2512
|
-
.tab.active { background: var(--accent); color: #fff; }
|
|
2513
|
-
|
|
2514
|
-
/* \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 */
|
|
2515
|
-
.cards {
|
|
2516
|
-
display: grid;
|
|
2517
|
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2518
|
-
gap: 12px;
|
|
2519
|
-
margin-bottom: 24px;
|
|
2520
|
-
}
|
|
2521
|
-
.card {
|
|
2522
|
-
background: var(--surface);
|
|
2523
|
-
border: 1px solid var(--border);
|
|
2524
|
-
border-radius: var(--r);
|
|
2525
|
-
padding: 16px;
|
|
2526
|
-
}
|
|
2527
|
-
.card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 6px; }
|
|
2528
|
-
.card-value { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
2529
|
-
.card-sub { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
|
2530
|
-
|
|
2531
|
-
/* \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 */
|
|
2532
|
-
.charts {
|
|
2533
|
-
display: grid;
|
|
2534
|
-
grid-template-columns: 2fr 1fr;
|
|
2535
|
-
gap: 16px;
|
|
2536
|
-
margin-bottom: 24px;
|
|
2537
|
-
}
|
|
2538
|
-
@media (max-width: 768px) { .charts { grid-template-columns: 1fr; } }
|
|
2539
|
-
|
|
2540
|
-
.panel {
|
|
2541
|
-
background: var(--surface);
|
|
2542
|
-
border: 1px solid var(--border);
|
|
2543
|
-
border-radius: var(--r);
|
|
2544
|
-
padding: 16px;
|
|
2545
|
-
}
|
|
2546
|
-
.panel-title {
|
|
2547
|
-
font-size: 12px;
|
|
2548
|
-
font-weight: 600;
|
|
2549
|
-
text-transform: uppercase;
|
|
2550
|
-
letter-spacing: 0.5px;
|
|
2551
|
-
color: var(--muted);
|
|
2552
|
-
margin-bottom: 14px;
|
|
2553
|
-
}
|
|
2554
|
-
.chart-wrap { position: relative; height: 220px; }
|
|
2555
|
-
|
|
2556
|
-
/* \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 */
|
|
2557
|
-
.section { margin-bottom: 24px; }
|
|
2558
|
-
.section-title {
|
|
2559
|
-
font-size: 12px;
|
|
2560
|
-
font-weight: 600;
|
|
2561
|
-
text-transform: uppercase;
|
|
2562
|
-
letter-spacing: 0.5px;
|
|
2563
|
-
color: var(--muted);
|
|
2564
|
-
margin-bottom: 10px;
|
|
3012
|
+
function TweakSection({ label }) { return <div className="twk-sect">{label}</div>; }
|
|
3013
|
+
function TweakRow({ label, value, children, inline }) {
|
|
3014
|
+
return (
|
|
3015
|
+
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
|
3016
|
+
<div className="twk-lbl"><span>{label}</span>{value != null && <span className="twk-val">{value}</span>}</div>
|
|
3017
|
+
{children}
|
|
3018
|
+
</div>
|
|
3019
|
+
);
|
|
2565
3020
|
}
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
border-radius: var(--r);
|
|
2573
|
-
overflow: hidden;
|
|
2574
|
-
font-size: 13px;
|
|
2575
|
-
}
|
|
2576
|
-
thead { background: rgba(255,255,255,0.03); }
|
|
2577
|
-
th {
|
|
2578
|
-
padding: 10px 14px;
|
|
2579
|
-
text-align: left;
|
|
2580
|
-
font-weight: 600;
|
|
2581
|
-
color: var(--muted);
|
|
2582
|
-
font-size: 11px;
|
|
2583
|
-
text-transform: uppercase;
|
|
2584
|
-
letter-spacing: 0.4px;
|
|
2585
|
-
border-bottom: 1px solid var(--border);
|
|
2586
|
-
}
|
|
2587
|
-
th.num, td.num { text-align: right; }
|
|
2588
|
-
td {
|
|
2589
|
-
padding: 10px 14px;
|
|
2590
|
-
border-bottom: 1px solid rgba(48,54,61,0.5);
|
|
2591
|
-
font-variant-numeric: tabular-nums;
|
|
2592
|
-
color: var(--text);
|
|
2593
|
-
}
|
|
2594
|
-
tbody tr:last-child td { border-bottom: none; }
|
|
2595
|
-
tbody tr:hover td { background: rgba(255,255,255,0.02); }
|
|
2596
|
-
|
|
2597
|
-
.bar-wrap { width: 80px; height: 6px; background: var(--border); border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 8px; }
|
|
2598
|
-
.bar-fill { height: 100%; border-radius: 3px; background: var(--accent); }
|
|
2599
|
-
|
|
2600
|
-
/* \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 */
|
|
2601
|
-
.forecast-grid {
|
|
2602
|
-
display: grid;
|
|
2603
|
-
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
2604
|
-
gap: 12px;
|
|
3021
|
+
function TweakSlider({ label, value, min=0, max=100, step=1, unit='', onChange }) {
|
|
3022
|
+
return (
|
|
3023
|
+
<TweakRow label={label} value={value + unit}>
|
|
3024
|
+
<input type="range" className="twk-slider" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
3025
|
+
</TweakRow>
|
|
3026
|
+
);
|
|
2605
3027
|
}
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
3028
|
+
function TweakToggle({ label, value, onChange }) {
|
|
3029
|
+
return (
|
|
3030
|
+
<div className="twk-row twk-row-h">
|
|
3031
|
+
<div className="twk-lbl"><span>{label}</span></div>
|
|
3032
|
+
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'} onClick={() => onChange(!value)}><i /></button>
|
|
3033
|
+
</div>
|
|
3034
|
+
);
|
|
2613
3035
|
}
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
font-size: 12px;
|
|
2623
|
-
font-weight: 600;
|
|
2624
|
-
text-transform: uppercase;
|
|
2625
|
-
letter-spacing: 0.5px;
|
|
2626
|
-
color: var(--muted);
|
|
2627
|
-
margin-bottom: 10px;
|
|
2628
|
-
user-select: none;
|
|
2629
|
-
}
|
|
2630
|
-
details summary::before { content: '\u25B6'; font-size: 10px; transition: transform 0.15s; }
|
|
2631
|
-
details[open] summary::before { transform: rotate(90deg); }
|
|
2632
|
-
|
|
2633
|
-
/* \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 */
|
|
2634
|
-
.footer {
|
|
2635
|
-
font-size: 11px;
|
|
2636
|
-
color: var(--muted);
|
|
2637
|
-
text-align: center;
|
|
2638
|
-
padding: 16px 0 0;
|
|
3036
|
+
function TweakSelect({ label, value, options, onChange }) {
|
|
3037
|
+
return (
|
|
3038
|
+
<TweakRow label={label}>
|
|
3039
|
+
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
|
3040
|
+
{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>; })}
|
|
3041
|
+
</select>
|
|
3042
|
+
</TweakRow>
|
|
3043
|
+
);
|
|
2639
3044
|
}
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
3045
|
+
function TweakRadio({ label, value, options, onChange }) {
|
|
3046
|
+
const trackRef = React.useRef(null);
|
|
3047
|
+
const [dragging, setDragging] = React.useState(false);
|
|
3048
|
+
const valueRef = React.useRef(value); valueRef.current = value;
|
|
3049
|
+
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
|
3050
|
+
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
|
3051
|
+
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] || 0);
|
|
3052
|
+
if (!fitsAsSegments) {
|
|
3053
|
+
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; };
|
|
3054
|
+
return <TweakSelect label={label} value={value} options={options} onChange={(s) => onChange(resolve(s))} />;
|
|
3055
|
+
}
|
|
3056
|
+
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
|
3057
|
+
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
|
3058
|
+
const n = opts.length;
|
|
3059
|
+
const segAt = (clientX) => {
|
|
3060
|
+
const r = trackRef.current.getBoundingClientRect();
|
|
3061
|
+
const i = Math.floor(((clientX - r.left - 2) / (r.width - 4)) * n);
|
|
3062
|
+
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
|
3063
|
+
};
|
|
3064
|
+
const onPointerDown = (e) => {
|
|
3065
|
+
setDragging(true);
|
|
3066
|
+
const v0 = segAt(e.clientX); if (v0 !== valueRef.current) onChange(v0);
|
|
3067
|
+
const move = (ev) => { if (!trackRef.current) return; const v = segAt(ev.clientX); if (v !== valueRef.current) onChange(v); };
|
|
3068
|
+
const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
3069
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
3070
|
+
};
|
|
3071
|
+
return (
|
|
3072
|
+
<TweakRow label={label}>
|
|
3073
|
+
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown} className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
|
3074
|
+
<div className="twk-seg-thumb" style={{ left: 'calc(2px + ' + idx + ' * (100% - 4px) / ' + n + ')', width: 'calc((100% - 4px) / ' + n + ')' }} />
|
|
3075
|
+
{opts.map((o) => <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>{o.label}</button>)}
|
|
3076
|
+
</div>
|
|
3077
|
+
</TweakRow>
|
|
3078
|
+
);
|
|
3079
|
+
}
|
|
3080
|
+
function __twkIsLight(hex) {
|
|
3081
|
+
const h = String(hex).replace('#', '');
|
|
3082
|
+
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
|
3083
|
+
const n = parseInt(x.slice(0, 6), 16);
|
|
3084
|
+
if (Number.isNaN(n)) return true;
|
|
3085
|
+
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
|
3086
|
+
return r * 299 + g * 587 + b * 114 > 148000;
|
|
3087
|
+
}
|
|
3088
|
+
const __TwkCheck = ({ light }) => (
|
|
3089
|
+
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
3090
|
+
<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'} />
|
|
3091
|
+
</svg>
|
|
3092
|
+
);
|
|
3093
|
+
function TweakColor({ label, value, options, onChange }) {
|
|
3094
|
+
if (!options || !options.length) {
|
|
3095
|
+
return (
|
|
3096
|
+
<div className="twk-row twk-row-h">
|
|
3097
|
+
<div className="twk-lbl"><span>{label}</span></div>
|
|
3098
|
+
<input type="color" className="twk-swatch" value={value} onChange={(e) => onChange(e.target.value)} />
|
|
3099
|
+
</div>
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
|
3103
|
+
const cur = key(value);
|
|
3104
|
+
return (
|
|
3105
|
+
<TweakRow label={label}>
|
|
3106
|
+
<div className="twk-chips" role="radiogroup">
|
|
3107
|
+
{options.map((o, i) => {
|
|
3108
|
+
const colors = Array.isArray(o) ? o : [o];
|
|
3109
|
+
const [hero, ...rest] = colors;
|
|
3110
|
+
const sup = rest.slice(0, 4);
|
|
3111
|
+
const on = key(o) === cur;
|
|
3112
|
+
return (
|
|
3113
|
+
<button key={i} type="button" className="twk-chip" role="radio" data-on={on ? '1' : '0'} style={{ background: hero }} onClick={() => onChange(o)}>
|
|
3114
|
+
{sup.length > 0 && <span>{sup.map((c, j) => <i key={j} style={{ background: c }} />)}</span>}
|
|
3115
|
+
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
|
3116
|
+
</button>
|
|
3117
|
+
);
|
|
3118
|
+
})}
|
|
3119
|
+
</div>
|
|
3120
|
+
</TweakRow>
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
Object.assign(window, { useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, TweakToggle, TweakRadio, TweakSelect, TweakColor });
|
|
3124
|
+
</script>
|
|
2644
3125
|
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3126
|
+
<script type="text/babel">
|
|
3127
|
+
// tw-charts.jsx
|
|
3128
|
+
const { useRef, useState, useEffect, useLayoutEffect } = React;
|
|
3129
|
+
function useMeasure() {
|
|
3130
|
+
const ref = useRef(null);
|
|
3131
|
+
const [w, setW] = useState(0);
|
|
3132
|
+
useLayoutEffect(() => {
|
|
3133
|
+
if (!ref.current) return;
|
|
3134
|
+
const ro = new ResizeObserver((es) => setW(es[0].contentRect.width));
|
|
3135
|
+
ro.observe(ref.current);
|
|
3136
|
+
setW(ref.current.clientWidth);
|
|
3137
|
+
return () => ro.disconnect();
|
|
3138
|
+
}, []);
|
|
3139
|
+
return [ref, w];
|
|
3140
|
+
}
|
|
3141
|
+
function useCountUp(target, { duration = 700, enabled = true, decimals = 0 } = {}) {
|
|
3142
|
+
const [val, setVal] = useState(enabled ? 0 : target);
|
|
3143
|
+
const fromRef = useRef(enabled ? 0 : target);
|
|
3144
|
+
useEffect(() => {
|
|
3145
|
+
if (!enabled) { setVal(target); return; }
|
|
3146
|
+
const from = fromRef.current, start = performance.now();
|
|
3147
|
+
let raf;
|
|
3148
|
+
const tick = (t) => {
|
|
3149
|
+
const p = Math.min(1, (t - start) / duration);
|
|
3150
|
+
const e = 1 - Math.pow(1 - p, 3);
|
|
3151
|
+
setVal(from + (target - from) * e);
|
|
3152
|
+
if (p < 1) raf = requestAnimationFrame(tick);
|
|
3153
|
+
else fromRef.current = target;
|
|
3154
|
+
};
|
|
3155
|
+
raf = requestAnimationFrame(tick);
|
|
3156
|
+
return () => cancelAnimationFrame(raf);
|
|
3157
|
+
}, [target, enabled, duration]);
|
|
3158
|
+
return val;
|
|
3159
|
+
}
|
|
3160
|
+
function LineChart({ current, previous, n, color = '#58a6ff', compare = false, animate = true, fmt }) {
|
|
3161
|
+
const [ref, w] = useMeasure();
|
|
3162
|
+
const [hover, setHover] = useState(null);
|
|
3163
|
+
const H = 248, padT = 16, padB = 26, padL = 8, padR = 8;
|
|
3164
|
+
const innerW = Math.max(1, w - padL - padR), innerH = H - padT - padB;
|
|
3165
|
+
const series = compare && previous ? [...current, ...previous] : current;
|
|
3166
|
+
const max = Math.max(...series, 0.0001) * 1.18;
|
|
3167
|
+
const X = (i) => padL + (i / Math.max(n - 1, 1)) * innerW;
|
|
3168
|
+
const Y = (v) => padT + innerH - (v / max) * innerH;
|
|
3169
|
+
const path = (arr) => arr.map((v, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(v).toFixed(1)).join(' ');
|
|
3170
|
+
const area = (arr) => path(arr) + ' L' + X(n - 1).toFixed(1) + ' ' + (padT + innerH).toFixed(1) + ' L' + padL.toFixed(1) + ' ' + (padT + innerH).toFixed(1) + ' Z';
|
|
3171
|
+
const gid = 'lg' + color.replace('#', '');
|
|
3172
|
+
const onMove = (e) => {
|
|
3173
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
3174
|
+
const x = e.clientX - r.left - padL;
|
|
3175
|
+
const i = Math.max(0, Math.min(n - 1, Math.round((x / innerW) * (n - 1))));
|
|
3176
|
+
setHover(i);
|
|
3177
|
+
};
|
|
3178
|
+
return (
|
|
3179
|
+
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
|
3180
|
+
{w > 0 && (
|
|
3181
|
+
<svg width={w} height={H} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{ display: 'block', cursor: 'crosshair' }}>
|
|
3182
|
+
<defs>
|
|
3183
|
+
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
3184
|
+
<stop offset="0%" stopColor={color} stopOpacity="0.22" />
|
|
3185
|
+
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
3186
|
+
</linearGradient>
|
|
3187
|
+
</defs>
|
|
3188
|
+
{[0.25, 0.5, 0.75, 1].map((g, i) => (
|
|
3189
|
+
<line key={i} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke="#21262d" strokeWidth="1" />
|
|
3190
|
+
))}
|
|
3191
|
+
{current.length > 1 && <path d={area(current)} fill={'url(#' + gid + ')'} className={animate ? 'tw-draw-area' : ''} />}
|
|
3192
|
+
{compare && previous && previous.length > 1 && (
|
|
3193
|
+
<path d={path(previous)} fill="none" stroke="#6e7681" strokeWidth="1.6" strokeDasharray="5 4" opacity="0.85" />
|
|
3194
|
+
)}
|
|
3195
|
+
{current.length > 1 && <path d={path(current)} fill="none" stroke={color} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" className={animate ? 'tw-draw-line' : ''} />}
|
|
3196
|
+
{hover != null && (
|
|
3197
|
+
<g>
|
|
3198
|
+
<line x1={X(hover)} x2={X(hover)} y1={padT} y2={padT + innerH} stroke="#484f58" strokeWidth="1" strokeDasharray="3 3" />
|
|
3199
|
+
{compare && previous && previous[hover] != null && <circle cx={X(hover)} cy={Y(previous[hover])} r="3.5" fill="#21262d" stroke="#6e7681" strokeWidth="1.6" />}
|
|
3200
|
+
<circle cx={X(hover)} cy={Y(current[hover])} r="4.5" fill="#0d1117" stroke={color} strokeWidth="2.2" />
|
|
3201
|
+
</g>
|
|
3202
|
+
)}
|
|
3203
|
+
</svg>
|
|
3204
|
+
)}
|
|
3205
|
+
{hover != null && w > 0 && (
|
|
3206
|
+
<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)' }}>
|
|
3207
|
+
<div style={{ color: '#7d8590', marginBottom: 3 }}>point {hover + 1}/{n}</div>
|
|
3208
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#e6edf3', fontVariantNumeric: 'tabular-nums' }}>
|
|
3209
|
+
<span style={{ color }}>● current</span><b>{fmt ? fmt(current[hover]) : current[hover].toFixed(4)}</b>
|
|
3210
|
+
</div>
|
|
3211
|
+
{compare && previous && previous[hover] != null && (
|
|
3212
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#8b949e', fontVariantNumeric: 'tabular-nums', marginTop: 2 }}>
|
|
3213
|
+
<span>○ previous</span><span>{fmt ? fmt(previous[hover]) : previous[hover].toFixed(4)}</span>
|
|
3214
|
+
</div>
|
|
3215
|
+
)}
|
|
3216
|
+
</div>
|
|
3217
|
+
)}
|
|
2650
3218
|
</div>
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
<
|
|
2659
|
-
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
function Doughnut({ data, total, fmt, active, onHover }) {
|
|
3222
|
+
const size = 188, stroke = 26, r = (size - stroke) / 2, c = 2 * Math.PI * r;
|
|
3223
|
+
let acc = 0;
|
|
3224
|
+
const safeTotal = total || 0.0001;
|
|
3225
|
+
return (
|
|
3226
|
+
<svg width={size} height={size} viewBox={'0 0 ' + size + ' ' + size}>
|
|
3227
|
+
<g transform={'rotate(-90 ' + (size / 2) + ' ' + (size / 2) + ')'}>
|
|
3228
|
+
{data.map((d) => {
|
|
3229
|
+
const frac = d.cost / safeTotal;
|
|
3230
|
+
const dash = frac * c;
|
|
3231
|
+
const seg = (
|
|
3232
|
+
<circle key={d.id} cx={size / 2} cy={size / 2} r={r} fill="none"
|
|
3233
|
+
stroke={d.color} strokeWidth={active === d.id ? stroke + 5 : stroke}
|
|
3234
|
+
strokeDasharray={dash + ' ' + (c - dash)} strokeDashoffset={-acc}
|
|
3235
|
+
opacity={active && active !== d.id ? 0.32 : 1}
|
|
3236
|
+
style={{ transition: 'opacity .15s, stroke-width .15s', cursor: 'pointer' }}
|
|
3237
|
+
onMouseEnter={() => onHover && onHover(d.id)} onMouseLeave={() => onHover && onHover(null)} />
|
|
3238
|
+
);
|
|
3239
|
+
acc += dash;
|
|
3240
|
+
return seg;
|
|
3241
|
+
})}
|
|
3242
|
+
</g>
|
|
3243
|
+
<text x="50%" y="46%" textAnchor="middle" fill="#7d8590" fontSize="11" style={{ textTransform: 'uppercase', letterSpacing: '.05em' }}>
|
|
3244
|
+
{active ? ((data.find((d) => d.id === active) || {}).id || 'total') : 'total'}
|
|
3245
|
+
</text>
|
|
3246
|
+
<text x="50%" y="58%" textAnchor="middle" fill="#e6edf3" fontSize="20" fontWeight="600" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
3247
|
+
{active ? fmt(((data.find((d) => d.id === active) || {}).cost) || 0) : fmt(total)}
|
|
3248
|
+
</text>
|
|
3249
|
+
</svg>
|
|
3250
|
+
);
|
|
3251
|
+
}
|
|
3252
|
+
function Sparkline({ data, color = '#58a6ff', w = 92, h = 30 }) {
|
|
3253
|
+
if (!data || data.length < 2) return null;
|
|
3254
|
+
const max = Math.max(...data, 0.0001), min = Math.min(...data);
|
|
3255
|
+
const X = (i) => (i / (data.length - 1)) * w;
|
|
3256
|
+
const Y = (v) => h - 2 - ((v - min) / (max - min || 1)) * (h - 4);
|
|
3257
|
+
const d = data.map((v, i) => (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(v).toFixed(1)).join(' ');
|
|
3258
|
+
return (
|
|
3259
|
+
<svg width={w} height={h} style={{ display: 'block' }}>
|
|
3260
|
+
<path d={d + ' L' + w + ' ' + h + ' L0 ' + h + ' Z'} fill={color} opacity="0.12" />
|
|
3261
|
+
<path d={d} fill="none" stroke={color} strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
|
|
3262
|
+
</svg>
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
function ShareBar({ frac, color }) {
|
|
3266
|
+
return (
|
|
3267
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3268
|
+
<div style={{ flex: 1, height: 6, background: '#21262d', borderRadius: 3, overflow: 'hidden', minWidth: 40 }}>
|
|
3269
|
+
<div style={{ width: (frac * 100).toFixed(1) + '%', height: '100%', background: color, borderRadius: 3, transition: 'width .4s' }} />
|
|
3270
|
+
</div>
|
|
3271
|
+
<span style={{ color: '#7d8590', fontSize: 11, width: 34, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{(frac * 100).toFixed(1)}%</span>
|
|
3272
|
+
</div>
|
|
3273
|
+
);
|
|
3274
|
+
}
|
|
3275
|
+
Object.assign(window, { useMeasure, useCountUp, LineChart, Doughnut, Sparkline, ShareBar });
|
|
3276
|
+
</script>
|
|
2660
3277
|
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
3278
|
+
<script type="text/babel">
|
|
3279
|
+
// tw-cards.jsx
|
|
3280
|
+
const { useState: useStateC } = React;
|
|
3281
|
+
const Ico = {
|
|
3282
|
+
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>,
|
|
3283
|
+
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>,
|
|
3284
|
+
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>,
|
|
3285
|
+
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>,
|
|
3286
|
+
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>,
|
|
3287
|
+
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>,
|
|
3288
|
+
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>,
|
|
3289
|
+
};
|
|
3290
|
+
function Header({ t, onOpenPalette }) {
|
|
3291
|
+
const [wsOpen, setWsOpen] = useStateC(false);
|
|
3292
|
+
const nav = ['Overview', 'Sessions', 'Users', 'Features', 'Settings'];
|
|
3293
|
+
return (
|
|
3294
|
+
<header className="tw-header">
|
|
3295
|
+
<div className="tw-hgroup">
|
|
3296
|
+
<div className="tw-logo">token<span>watch</span></div>
|
|
3297
|
+
<button className="tw-ws" onClick={() => setWsOpen((v) => !v)}>
|
|
3298
|
+
<span className="tw-ws-dot" />
|
|
3299
|
+
<span>tokenwatch</span>
|
|
3300
|
+
<span className="tw-ws-env">local</span>
|
|
3301
|
+
<Ico.chevron style={{ color: '#7d8590' }} />
|
|
3302
|
+
</button>
|
|
3303
|
+
<nav className="tw-nav">
|
|
3304
|
+
{nav.map((n, i) => <a key={n} className={i === 0 ? 'on' : ''} href="#" onClick={(e) => e.preventDefault()}>{n}</a>)}
|
|
3305
|
+
</nav>
|
|
3306
|
+
</div>
|
|
3307
|
+
<div className="tw-hgroup">
|
|
3308
|
+
{t.commandPalette && (
|
|
3309
|
+
<button className="tw-search" onClick={onOpenPalette}>
|
|
3310
|
+
<Ico.search style={{ color: '#7d8590' }} />
|
|
3311
|
+
<span>Search</span>
|
|
3312
|
+
<kbd>⌘K</kbd>
|
|
3313
|
+
</button>
|
|
3314
|
+
)}
|
|
3315
|
+
<button className="tw-btn-2"><Ico.download /> Export CSV</button>
|
|
3316
|
+
</div>
|
|
3317
|
+
</header>
|
|
3318
|
+
);
|
|
3319
|
+
}
|
|
3320
|
+
function BudgetBar({ t }) {
|
|
3321
|
+
const { BUDGET, fmtMoney } = window.TW;
|
|
3322
|
+
const { used, limit, daysLeft, cycleDays } = BUDGET;
|
|
3323
|
+
const pct = Math.min(used / Math.max(limit, 0.0001), 1);
|
|
3324
|
+
const elapsed = cycleDays - daysLeft;
|
|
3325
|
+
const barColor = pct < 0.5 ? '#3fb950' : pct < 0.8 ? '#d29922' : '#f85149';
|
|
3326
|
+
const projectedDaily = used / Math.max(elapsed, 1);
|
|
3327
|
+
const projected = used + projectedDaily * daysLeft;
|
|
3328
|
+
const projPct = Math.min(projected / Math.max(limit, 0.0001), 1);
|
|
3329
|
+
return (
|
|
3330
|
+
<div className="tw-budget">
|
|
3331
|
+
<div className="tw-budget-top">
|
|
3332
|
+
<div className="tw-budget-label">
|
|
3333
|
+
<span className="tw-bud-strong">Monthly budget</span>
|
|
3334
|
+
<span className="tw-bud-nums"><b>{fmtMoney(used)}</b> used of {fmtMoney(limit)}</span>
|
|
3335
|
+
</div>
|
|
3336
|
+
{t.budgetAlerts && (
|
|
3337
|
+
<div className="tw-bud-alert ok">
|
|
3338
|
+
<Ico.check style={{ color: '#3fb950' }} />
|
|
3339
|
+
Projected <b>{fmtMoney(projected)}</b> by cycle end
|
|
3340
|
+
</div>
|
|
3341
|
+
)}
|
|
3342
|
+
<div className="tw-budget-days">{daysLeft} days left · day {elapsed}/{cycleDays}</div>
|
|
3343
|
+
</div>
|
|
3344
|
+
<div className="tw-bar">
|
|
3345
|
+
<div className="tw-bar-fill" style={{ width: (pct * 100) + '%', background: barColor }} />
|
|
3346
|
+
{t.budgetAlerts && <div className="tw-bar-proj" style={{ left: (pct * 100) + '%', width: ((projPct - pct) * 100) + '%' }} />}
|
|
3347
|
+
{t.budgetAlerts && <div className="tw-bar-marker" style={{ left: (projPct * 100) + '%' }} data-tip={'proj ' + fmtMoney(projected)} />}
|
|
3348
|
+
</div>
|
|
2666
3349
|
</div>
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
function KpiCard({ label, value, sub, delta, deltaColor, spark, sparkColor, t }) {
|
|
3353
|
+
return (
|
|
3354
|
+
<div className="tw-kpi">
|
|
3355
|
+
<div className="tw-kpi-label">{label}</div>
|
|
3356
|
+
<div className="tw-kpi-main">
|
|
3357
|
+
<div className="tw-kpi-value">{value}</div>
|
|
3358
|
+
{t.kpiSparklines && spark && spark.length >= 2 && <Sparkline data={spark} color={sparkColor} w={84} h={28} />}
|
|
3359
|
+
</div>
|
|
3360
|
+
<div className="tw-kpi-foot">
|
|
3361
|
+
{delta && <span className="tw-delta" style={{ color: t.smartHighlight ? deltaColor : '#7d8590' }}>{delta}</span>}
|
|
3362
|
+
{sub && <span className="tw-kpi-sub">{sub}</span>}
|
|
3363
|
+
</div>
|
|
2670
3364
|
</div>
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
function KpiRow({ kpis, range, t }) {
|
|
3368
|
+
const { fmtUSD, fmtInt, seriesForRange } = window.TW;
|
|
3369
|
+
const s = seriesForRange(range).current;
|
|
3370
|
+
const callsSeries = s.map((v, i) => 6 + Math.abs(Math.sin(i * 1.7)) * 14);
|
|
3371
|
+
return (
|
|
3372
|
+
<div className="tw-kpis">
|
|
3373
|
+
<KpiCard t={t} label="Total cost" value={fmtUSD(kpis.cost)} delta="↑ 12% vs last week" deltaColor="#f85149" spark={s} sparkColor="#58a6ff" />
|
|
3374
|
+
<KpiCard t={t} label="Input tokens" value={fmtInt(kpis.inTok)} sub="tokens in" spark={s} sparkColor="#3fb950" />
|
|
3375
|
+
<KpiCard t={t} label="Output tokens" value={fmtInt(kpis.outTok)} sub="tokens out" spark={s.map((v) => v * 0.9)} sparkColor="#bc8cff" />
|
|
3376
|
+
<KpiCard t={t} label="Total calls" value={fmtInt(kpis.calls)} delta="↓ 5% vs last week" deltaColor="#3fb950" spark={callsSeries} sparkColor="#56d4dd" />
|
|
3377
|
+
<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" />
|
|
2674
3378
|
</div>
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
function TimeFilter({ range, setRange, t, setTweak }) {
|
|
3382
|
+
const ranges = ['1h', '24h', '7d', '30d', 'All'];
|
|
3383
|
+
return (
|
|
3384
|
+
<div className="tw-timefilter">
|
|
3385
|
+
<div className="tw-tabs">
|
|
3386
|
+
{ranges.map((r) => (
|
|
3387
|
+
<button key={r} className={'tw-tab' + (r === range ? ' on' : '')} onClick={() => setRange(r)}>{r}</button>
|
|
3388
|
+
))}
|
|
3389
|
+
</div>
|
|
3390
|
+
<label className="tw-compare">
|
|
3391
|
+
<button className="tw-toggle-sm" data-on={t.compareMode ? '1' : '0'} onClick={() => setTweak('compareMode', !t.compareMode)}><i /></button>
|
|
3392
|
+
Compare to previous period
|
|
3393
|
+
</label>
|
|
2678
3394
|
</div>
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
function ForecastCard({ label, value, sub, accent, flag }) {
|
|
3398
|
+
return (
|
|
3399
|
+
<div className={'tw-fc' + (flag ? ' flag' : '')}>
|
|
3400
|
+
<div className="tw-fc-label">{label}</div>
|
|
3401
|
+
<div className="tw-fc-value" style={accent ? { color: accent } : null}>{value}</div>
|
|
3402
|
+
{sub && <div className="tw-fc-sub">{sub}</div>}
|
|
2683
3403
|
</div>
|
|
2684
|
-
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
function ForecastSection({ t }) {
|
|
3407
|
+
const { fmtUSD, fmtMoney, BUDGET } = window.TW;
|
|
3408
|
+
const daily = BUDGET.daily != null ? BUDGET.daily : 0.8473;
|
|
3409
|
+
const burnHr = daily / 24;
|
|
3410
|
+
const remaining = daily * BUDGET.daysLeft;
|
|
3411
|
+
const projCycle = BUDGET.used + remaining;
|
|
3412
|
+
const g = t.forecastScenario / 100;
|
|
3413
|
+
const scenarioCycle = BUDGET.used + remaining * (1 + g);
|
|
3414
|
+
const over = scenarioCycle > BUDGET.limit;
|
|
3415
|
+
return (
|
|
3416
|
+
<section className="tw-section">
|
|
3417
|
+
<div className="tw-sec-head"><h3>Cost forecast</h3><span className="tw-sec-sub">based on current run-rate</span></div>
|
|
3418
|
+
<div className="tw-forecast">
|
|
3419
|
+
<ForecastCard label="Projected daily" value={fmtUSD(daily, 2)} sub="next 24h at this rate" />
|
|
3420
|
+
<ForecastCard label="Projected this cycle" value={fmtMoney(projCycle)} sub={'of ' + fmtMoney(BUDGET.limit) + ' budget'} />
|
|
3421
|
+
<ForecastCard label="Burn rate" value={fmtUSD(burnHr, 4)} sub="per hour" accent="#e3b341" />
|
|
3422
|
+
<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} />
|
|
3423
|
+
</div>
|
|
3424
|
+
</section>
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3427
|
+
Object.assign(window, { Ico, Header, BudgetBar, KpiCard, KpiRow, TimeFilter, ForecastCard, ForecastSection });
|
|
3428
|
+
</script>
|
|
2685
3429
|
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
3430
|
+
<script type="text/babel">
|
|
3431
|
+
// tw-table.jsx
|
|
3432
|
+
const { useState: useStateT } = React;
|
|
3433
|
+
function ModelTable({ models, total, t, onRowClick, exampleHover }) {
|
|
3434
|
+
const { fmtInt, fmtUSD, fmtCompact } = window.TW;
|
|
3435
|
+
const [sort, setSort] = useStateT({ key: 'cost', dir: -1 });
|
|
3436
|
+
const [hoverRow, setHoverRow] = useStateT(null);
|
|
3437
|
+
const cols = [
|
|
3438
|
+
{ key: 'id', label: 'Model', align: 'left' },
|
|
3439
|
+
{ key: 'calls', label: 'Calls', align: 'right' },
|
|
3440
|
+
{ key: 'inTok', label: 'In tokens', align: 'right' },
|
|
3441
|
+
{ key: 'outTok', label: 'Out tokens', align: 'right' },
|
|
3442
|
+
{ key: 'cost', label: 'Cost', align: 'right' },
|
|
3443
|
+
{ key: 'share', label: 'Share', align: 'left' },
|
|
3444
|
+
{ key: 'avg', label: 'Avg / call', align: 'right' },
|
|
3445
|
+
];
|
|
3446
|
+
const safeTotal = total || 0.0001;
|
|
3447
|
+
const rows = [...models].map((m) => ({ ...m, avg: m.cost / Math.max(m.calls, 1), share: m.cost / safeTotal }));
|
|
3448
|
+
if (t.tableSort) {
|
|
3449
|
+
rows.sort((a, b) => {
|
|
3450
|
+
const A = a[sort.key], B = b[sort.key];
|
|
3451
|
+
if (typeof A === 'string') return A.localeCompare(B) * sort.dir;
|
|
3452
|
+
return (A - B) * sort.dir;
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
const clickHeader = (key) => {
|
|
3456
|
+
if (!t.tableSort) return;
|
|
3457
|
+
setSort((s) => s.key === key ? { key, dir: -s.dir } : { key, dir: key === 'id' ? 1 : -1 });
|
|
3458
|
+
};
|
|
3459
|
+
return (
|
|
3460
|
+
<section className="tw-section">
|
|
3461
|
+
<div className="tw-sec-head">
|
|
3462
|
+
<h3>Model breakdown</h3>
|
|
3463
|
+
<span className="tw-sec-sub">{models.length} models · click a row for detail</span>
|
|
2691
3464
|
</div>
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
3465
|
+
<div className="tw-table-wrap">
|
|
3466
|
+
<table className={'tw-table' + (t.density === 'compact' ? ' compact' : '')}>
|
|
3467
|
+
<thead>
|
|
3468
|
+
<tr>
|
|
3469
|
+
{cols.map((c) => (
|
|
3470
|
+
<th key={c.key} style={{ textAlign: c.align, cursor: t.tableSort && c.key !== 'share' ? 'pointer' : 'default' }}
|
|
3471
|
+
onClick={() => c.key !== 'share' && clickHeader(c.key)}>
|
|
3472
|
+
<span className="tw-th-inner" style={{ justifyContent: c.align === 'right' ? 'flex-end' : 'flex-start' }}>
|
|
3473
|
+
{c.label}
|
|
3474
|
+
{t.tableSort && sort.key === c.key && <span className="tw-sort">{sort.dir < 0 ? '\u25BE' : '\u25B4'}</span>}
|
|
3475
|
+
</span>
|
|
3476
|
+
</th>
|
|
3477
|
+
))}
|
|
3478
|
+
</tr>
|
|
3479
|
+
</thead>
|
|
3480
|
+
<tbody>
|
|
3481
|
+
{rows.map((m) => {
|
|
3482
|
+
const isExample = exampleHover && m.id === 'claude-sonnet-4-6';
|
|
3483
|
+
return (
|
|
3484
|
+
<tr key={m.id} className={'tw-row' + (isExample ? ' example' : '')}
|
|
3485
|
+
onMouseEnter={() => setHoverRow(m.id)} onMouseLeave={() => setHoverRow(null)}
|
|
3486
|
+
onClick={() => onRowClick(m)}>
|
|
3487
|
+
<td>
|
|
3488
|
+
<div className="tw-model-cell">
|
|
3489
|
+
<span className="tw-model-dot" style={{ background: m.color }} />
|
|
3490
|
+
<span className="tw-model-name">{m.id}</span>
|
|
3491
|
+
<span className="tw-model-prov">{m.provider}</span>
|
|
3492
|
+
</div>
|
|
3493
|
+
</td>
|
|
3494
|
+
<td className="tw-num-cell">{fmtInt(m.calls)}</td>
|
|
3495
|
+
<td className="tw-num-cell">{fmtCompact(m.inTok)}</td>
|
|
3496
|
+
<td className="tw-num-cell">{fmtCompact(m.outTok)}</td>
|
|
3497
|
+
<td className="tw-num-cell tw-cost">{fmtUSD(m.cost, 4)}</td>
|
|
3498
|
+
<td style={{ minWidth: 140 }}><ShareBar frac={m.share} color={m.color} /></td>
|
|
3499
|
+
<td className="tw-num-cell">
|
|
3500
|
+
{hoverRow === m.id && t.tableSort ? (
|
|
3501
|
+
<div className="tw-row-actions" onClick={(e) => e.stopPropagation()}>
|
|
3502
|
+
<button onClick={() => onRowClick(m)}>Details</button>
|
|
3503
|
+
<button>Alert</button>
|
|
3504
|
+
</div>
|
|
3505
|
+
) : (
|
|
3506
|
+
<span>{fmtUSD(m.avg, 4)}</span>
|
|
3507
|
+
)}
|
|
3508
|
+
</td>
|
|
3509
|
+
</tr>
|
|
3510
|
+
);
|
|
3511
|
+
})}
|
|
3512
|
+
</tbody>
|
|
3513
|
+
</table>
|
|
2697
3514
|
</div>
|
|
2698
|
-
</
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
<div id="model-table-wrap"></div>
|
|
2704
|
-
</div>
|
|
2705
|
-
|
|
2706
|
-
<details id="users-section" style="margin-bottom:24px;">
|
|
2707
|
-
<summary>By user</summary>
|
|
2708
|
-
<div id="user-table-wrap"></div>
|
|
2709
|
-
</details>
|
|
3515
|
+
</section>
|
|
3516
|
+
);
|
|
3517
|
+
}
|
|
3518
|
+
Object.assign(window, { ModelTable });
|
|
3519
|
+
</script>
|
|
2710
3520
|
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
3521
|
+
<script type="text/babel">
|
|
3522
|
+
// tw-panel.jsx
|
|
3523
|
+
const { useEffect: useEffectP } = React;
|
|
3524
|
+
function SlideOver({ model, onClose, t }) {
|
|
3525
|
+
const { fmtInt, fmtUSD, callsForModel, fmtAgo } = window.TW;
|
|
3526
|
+
useEffectP(() => {
|
|
3527
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
3528
|
+
window.addEventListener('keydown', onKey);
|
|
3529
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
3530
|
+
}, [onClose]);
|
|
3531
|
+
const open = !!model;
|
|
3532
|
+
const m = model;
|
|
3533
|
+
const calls = m ? callsForModel(m.id, 5) : [];
|
|
3534
|
+
const totalIn = m ? m.inTok : 0, totalOut = m ? m.outTok : 0;
|
|
3535
|
+
return (
|
|
3536
|
+
<>
|
|
3537
|
+
<div className={'tw-scrim' + (open ? ' open' : '')} onClick={onClose} />
|
|
3538
|
+
<aside className={'tw-slideover' + (open ? ' open' : '')}>
|
|
3539
|
+
{m && (
|
|
3540
|
+
<div className="tw-so-inner">
|
|
3541
|
+
<div className="tw-so-head">
|
|
3542
|
+
<div className="tw-so-title">
|
|
3543
|
+
<span className="tw-model-dot lg" style={{ background: m.color }} />
|
|
3544
|
+
<div>
|
|
3545
|
+
<div className="tw-so-name">{m.id}</div>
|
|
3546
|
+
<div className="tw-so-prov">{m.provider} · {fmtInt(m.calls)} calls in window</div>
|
|
3547
|
+
</div>
|
|
3548
|
+
</div>
|
|
3549
|
+
<button className="tw-so-close" onClick={onClose}>✕</button>
|
|
3550
|
+
</div>
|
|
3551
|
+
<div className="tw-so-stats">
|
|
3552
|
+
<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>
|
|
3553
|
+
<div className="tw-so-stat"><div className="tw-so-stat-l">Calls</div><div className="tw-so-stat-v">{fmtInt(m.calls)}</div></div>
|
|
3554
|
+
<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>
|
|
3555
|
+
</div>
|
|
3556
|
+
<div className="tw-so-metarow">
|
|
3557
|
+
<div><span className="tw-so-meta-l">In tokens</span><span className="tw-so-meta-v">{fmtInt(totalIn)}</span></div>
|
|
3558
|
+
<div><span className="tw-so-meta-l">Out tokens</span><span className="tw-so-meta-v">{fmtInt(totalOut)}</span></div>
|
|
3559
|
+
<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>
|
|
3560
|
+
</div>
|
|
3561
|
+
<div className="tw-so-section-label">Last 5 calls (estimated)</div>
|
|
3562
|
+
<div className="tw-so-calls">
|
|
3563
|
+
{calls.map((c) => (
|
|
3564
|
+
<div key={c.id} className="tw-so-call">
|
|
3565
|
+
<div className="tw-so-call-top">
|
|
3566
|
+
<span className="tw-so-call-time">{fmtAgo(c.secondsAgo)}</span>
|
|
3567
|
+
<span className="tw-feat" style={{ color: c.featureColor, borderColor: c.featureColor + '55' }}>{c.feature}</span>
|
|
3568
|
+
<span className="tw-so-call-cost">{fmtUSD(c.cost, 4)}</span>
|
|
3569
|
+
</div>
|
|
3570
|
+
<div className="tw-so-call-bot">
|
|
3571
|
+
<span>{fmtInt(c.inTok)} in · {fmtInt(c.outTok)} out</span>
|
|
3572
|
+
<span className="tw-so-call-lat">{c.latency}ms{c.status === 'slow' ? ' \xB7 slow' : c.status === 'error' ? ' \xB7 error' : ''}</span>
|
|
3573
|
+
</div>
|
|
3574
|
+
</div>
|
|
3575
|
+
))}
|
|
3576
|
+
</div>
|
|
3577
|
+
<button className="tw-so-viewall">View all {fmtInt(m.calls)} calls <Ico.arrow /></button>
|
|
3578
|
+
</div>
|
|
3579
|
+
)}
|
|
3580
|
+
</aside>
|
|
3581
|
+
</>
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
Object.assign(window, { SlideOver });
|
|
3585
|
+
</script>
|
|
2715
3586
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
3587
|
+
<script type="text/babel">
|
|
3588
|
+
// tw-activity.jsx
|
|
3589
|
+
const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React;
|
|
3590
|
+
function LiveActivity({ t }) {
|
|
3591
|
+
const { seedFeed, makeCall, fmtUSD, fmtInt, fmtAgo } = window.TW;
|
|
3592
|
+
const [feed, setFeed] = useStateA(() => seedFeed(14));
|
|
3593
|
+
const [now, setNow] = useStateA(Date.now());
|
|
3594
|
+
const [paused, setPaused] = useStateA(false);
|
|
3595
|
+
const [collapsed, setCollapsed] = useStateA(false);
|
|
3596
|
+
const [filter, setFilter] = useStateA('all');
|
|
3597
|
+
const seedRef = useRefA(5000);
|
|
3598
|
+
const streaming = t.liveFeed && t.animLevel !== 'minimal' && !paused;
|
|
3599
|
+
useEffectA(() => {
|
|
3600
|
+
if (!streaming) return;
|
|
3601
|
+
const iv = setInterval(() => {
|
|
3602
|
+
const c = makeCall(0, seedRef.current++);
|
|
3603
|
+
c.ts = Date.now(); c.fresh = true;
|
|
3604
|
+
setFeed((f) => [c, ...f].slice(0, 40));
|
|
3605
|
+
}, t.animLevel === 'subtle' ? 3600 : 2100);
|
|
3606
|
+
return () => clearInterval(iv);
|
|
3607
|
+
}, [streaming, t.animLevel]);
|
|
3608
|
+
useEffectA(() => {
|
|
3609
|
+
const iv = setInterval(() => setNow(Date.now()), 1000);
|
|
3610
|
+
return () => clearInterval(iv);
|
|
3611
|
+
}, []);
|
|
3612
|
+
const shown = feed
|
|
3613
|
+
.map((c) => ({ ...c, secondsAgo: Math.max(0, Math.round((now - c.ts) / 1000)) }))
|
|
3614
|
+
.filter((c) => filter === 'all' ? true : filter === 'errors' ? c.status !== 'ok' : c.cost >= 0.001)
|
|
3615
|
+
.slice(0, 10);
|
|
3616
|
+
return (
|
|
3617
|
+
<section className="tw-section tw-activity">
|
|
3618
|
+
<div className="tw-sec-head">
|
|
3619
|
+
<div className="tw-act-title">
|
|
3620
|
+
<button className="tw-collapse" onClick={() => setCollapsed((v) => !v)} style={{ transform: collapsed ? 'rotate(-90deg)' : 'none' }}><Ico.chevron /></button>
|
|
3621
|
+
<span className={'tw-live-dot' + (streaming ? ' on' : '')} />
|
|
3622
|
+
<h3>Live activity</h3>
|
|
3623
|
+
<span className="tw-sec-sub">{streaming ? 'streaming' : t.liveFeed ? 'paused' : 'static'}</span>
|
|
3624
|
+
</div>
|
|
3625
|
+
<div className="tw-act-controls">
|
|
3626
|
+
<div className="tw-seg-sm">
|
|
3627
|
+
{[['all', 'All'], ['signal', 'Signal'], ['errors', 'Errors']].map(([k, l]) => (
|
|
3628
|
+
<button key={k} className={filter === k ? 'on' : ''} onClick={() => setFilter(k)}>{l}</button>
|
|
3629
|
+
))}
|
|
3630
|
+
</div>
|
|
3631
|
+
{t.liveFeed && (
|
|
3632
|
+
<button className="tw-act-pause" onClick={() => setPaused((p) => !p)}>{paused ? '\u25B6 Resume' : '\u275A\u275A Pause'}</button>
|
|
3633
|
+
)}
|
|
3634
|
+
</div>
|
|
2727
3635
|
</div>
|
|
2728
|
-
|
|
2729
|
-
<div
|
|
2730
|
-
|
|
3636
|
+
{!collapsed && (
|
|
3637
|
+
<div className="tw-feed">
|
|
3638
|
+
<div className="tw-feed-head">
|
|
3639
|
+
<span>Time</span><span>Model</span><span>Session</span><span>Feature</span><span style={{ textAlign: 'right' }}>Cost</span>
|
|
3640
|
+
</div>
|
|
3641
|
+
<div className="tw-feed-body">
|
|
3642
|
+
{shown.map((c) => (
|
|
3643
|
+
<div key={c.id} className={'tw-feed-row' + (c.fresh ? ' fresh' : '') + (c.status === 'error' ? ' err' : '')}>
|
|
3644
|
+
<span className="tw-feed-time">{c.secondsAgo === 0 ? 'now' : fmtAgo(c.secondsAgo)}</span>
|
|
3645
|
+
<span className="tw-feed-model"><span className="tw-model-dot" style={{ background: c.modelColor }} />{c.model}</span>
|
|
3646
|
+
<span className="tw-feed-sess">{c.session}</span>
|
|
3647
|
+
<span><span className="tw-feat sm" style={{ color: c.featureColor, borderColor: c.featureColor + '55' }}>{c.feature}</span></span>
|
|
3648
|
+
<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>
|
|
3649
|
+
</div>
|
|
3650
|
+
))}
|
|
3651
|
+
</div>
|
|
3652
|
+
</div>
|
|
3653
|
+
)}
|
|
3654
|
+
</section>
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
function CommandPalette({ open, onClose, onAction }) {
|
|
3658
|
+
const [q, setQ] = useStateA('');
|
|
3659
|
+
const inputRef = useRefA(null);
|
|
3660
|
+
useEffectA(() => { if (open && inputRef.current) inputRef.current.focus(); if (open) setQ(''); }, [open]);
|
|
3661
|
+
useEffectA(() => {
|
|
3662
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
3663
|
+
if (open) window.addEventListener('keydown', onKey);
|
|
3664
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
3665
|
+
}, [open, onClose]);
|
|
3666
|
+
const items = [
|
|
3667
|
+
{ g: 'Filter', label: 'Set range: Last 1h', act: { range: '1h' } },
|
|
3668
|
+
{ g: 'Filter', label: 'Set range: Last 24h', act: { range: '24h' } },
|
|
3669
|
+
{ g: 'Filter', label: 'Set range: Last 7 days', act: { range: '7d' } },
|
|
3670
|
+
{ g: 'Filter', label: 'Set range: Last 30 days', act: { range: '30d' } },
|
|
3671
|
+
{ g: 'Filter', label: 'Set range: All time', act: { range: 'All' } },
|
|
3672
|
+
{ g: 'Action', label: 'Export CSV', hint: '\u2318E' },
|
|
3673
|
+
{ g: 'Action', label: 'Create budget alert' },
|
|
3674
|
+
];
|
|
3675
|
+
const filtered = items.filter((i) => i.label.toLowerCase().includes(q.toLowerCase()));
|
|
3676
|
+
if (!open) return null;
|
|
3677
|
+
return (
|
|
3678
|
+
<div className="tw-cmdk-scrim" onClick={onClose}>
|
|
3679
|
+
<div className="tw-cmdk" onClick={(e) => e.stopPropagation()}>
|
|
3680
|
+
<div className="tw-cmdk-input">
|
|
3681
|
+
<Ico.search style={{ color: '#7d8590' }} />
|
|
3682
|
+
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search models, sessions, actions\u2026" />
|
|
3683
|
+
<kbd>esc</kbd>
|
|
3684
|
+
</div>
|
|
3685
|
+
<div className="tw-cmdk-list">
|
|
3686
|
+
{filtered.length === 0 && <div className="tw-cmdk-empty">No results for "{q}"</div>}
|
|
3687
|
+
{filtered.map((i, idx) => (
|
|
3688
|
+
<div key={idx} className="tw-cmdk-item" onClick={() => { onAction(i.act); onClose(); }}>
|
|
3689
|
+
<span className="tw-cmdk-g">{i.g}</span>
|
|
3690
|
+
<span className="tw-cmdk-l">{i.label}</span>
|
|
3691
|
+
{i.hint && <kbd>{i.hint}</kbd>}
|
|
3692
|
+
</div>
|
|
3693
|
+
))}
|
|
3694
|
+
</div>
|
|
2731
3695
|
</div>
|
|
2732
3696
|
</div>
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
</div><!-- /container -->
|
|
2738
|
-
|
|
2739
|
-
<script>
|
|
2740
|
-
(function () {
|
|
2741
|
-
'use strict';
|
|
2742
|
-
|
|
2743
|
-
// \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
|
|
2744
|
-
const PALETTE = [
|
|
2745
|
-
'#58a6ff','#3fb950','#f78166','#d29922','#bc8cff',
|
|
2746
|
-
'#79c0ff','#56d364','#ffa657','#ff7b72','#a5d6ff',
|
|
2747
|
-
];
|
|
2748
|
-
|
|
2749
|
-
// \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
|
|
2750
|
-
let evtSource = null;
|
|
2751
|
-
let activeFilter = '24h';
|
|
2752
|
-
let lineChart = null;
|
|
2753
|
-
let doughnutChart = null;
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
Object.assign(window, { LiveActivity, CommandPalette });
|
|
3700
|
+
</script>
|
|
2754
3701
|
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
3702
|
+
<script type="text/babel">
|
|
3703
|
+
// tw-data.jsx \u2014 mock data + formatters (always-populated baseline)
|
|
3704
|
+
const fmtUSD = (n, d = 4) =>
|
|
3705
|
+
'$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
3706
|
+
const fmtMoney = (n) =>
|
|
3707
|
+
'$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
3708
|
+
const fmtInt = (n) => Math.round(n || 0).toLocaleString('en-US');
|
|
3709
|
+
const fmtCompact = (n) => {
|
|
3710
|
+
n = n || 0;
|
|
3711
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/.?0+$/, '') + 'M';
|
|
3712
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/.?0+$/, '') + 'K';
|
|
3713
|
+
return String(Math.round(n));
|
|
3714
|
+
};
|
|
3715
|
+
const fmtAgo = (s) => {
|
|
3716
|
+
if (s < 60) return s + 's ago';
|
|
3717
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
3718
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
3719
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
3720
|
+
};
|
|
3721
|
+
const BASE_MODELS = [
|
|
3722
|
+
{ id: 'gpt-5-mini', provider: 'OpenAI', color: '#3fb950', calls: 94, inTok: 1040000, outTok: 118000, cost: 0.2110, latency: 590 },
|
|
3723
|
+
{ id: 'claude-sonnet-4-6', provider: 'Anthropic', color: '#bc8cff', calls: 58, inTok: 612000, outTok: 84000, cost: 0.3120, latency: 1840 },
|
|
3724
|
+
{ id: 'gemini-2.5-flash', provider: 'Google', color: '#58a6ff', calls: 47, inTok: 430000, outTok: 56000, cost: 0.1190, latency: 510 },
|
|
3725
|
+
{ id: 'claude-haiku-4-5', provider: 'Anthropic', color: '#f778ba', calls: 38, inTok: 268100, outTok: 38540, cost: 0.1240, latency: 680 },
|
|
3726
|
+
{ id: 'gpt-5.1', provider: 'OpenAI', color: '#e3b341', calls: 18, inTok: 88000, outTok: 12000, cost: 0.0613, latency: 2210 },
|
|
3727
|
+
{ id: 'gemini-2.5-pro', provider: 'Google', color: '#56d4dd', calls: 8, inTok: 42000, outTok: 4000, cost: 0.0200, latency: 1990 },
|
|
3728
|
+
];
|
|
3729
|
+
const RANGES = {
|
|
3730
|
+
'1h': { factor: 0.052, points: 12, label: 'last hour', step: '5 min' },
|
|
3731
|
+
'24h': { factor: 1, points: 24, label: 'last 24h', step: 'hour' },
|
|
3732
|
+
'7d': { factor: 6.4, points: 28, label: 'last 7 days', step: '6h' },
|
|
3733
|
+
'30d': { factor: 53.1, points: 30, label: 'last 30 days',step: 'day' },
|
|
3734
|
+
'All': { factor: 142, points: 26, label: 'all time', step: 'week' },
|
|
3735
|
+
};
|
|
3736
|
+
function modelsForRange(range) {
|
|
3737
|
+
const f = RANGES[range].factor;
|
|
3738
|
+
return BASE_MODELS.map((m) => ({
|
|
3739
|
+
...m,
|
|
3740
|
+
calls: Math.max(1, Math.round(m.calls * f)),
|
|
3741
|
+
inTok: Math.round(m.inTok * f),
|
|
3742
|
+
outTok: Math.round(m.outTok * f),
|
|
3743
|
+
cost: m.cost * f,
|
|
3744
|
+
}));
|
|
3745
|
+
}
|
|
3746
|
+
function kpisForRange(range) {
|
|
3747
|
+
const ms = modelsForRange(range);
|
|
3748
|
+
const sum = (k) => ms.reduce((a, m) => a + m[k], 0);
|
|
3749
|
+
const cost = sum('cost'), calls = sum('calls');
|
|
3750
|
+
return {
|
|
3751
|
+
cost, calls,
|
|
3752
|
+
inTok: sum('inTok'), outTok: sum('outTok'),
|
|
3753
|
+
models: ms,
|
|
3754
|
+
burnHr: cost / ({ '1h': 1, '24h': 24, '7d': 168, '30d': 720, 'All': 3408 }[range]),
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
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];
|
|
3758
|
+
function shapeFor(n) {
|
|
3759
|
+
const out = [];
|
|
3760
|
+
for (let i = 0; i < n; i++) {
|
|
3761
|
+
const t = (i / n) * DAY_SHAPE.length;
|
|
3762
|
+
const a = DAY_SHAPE[Math.floor(t) % DAY_SHAPE.length];
|
|
3763
|
+
const b = DAY_SHAPE[(Math.floor(t) + 1) % DAY_SHAPE.length];
|
|
3764
|
+
out.push(a + (b - a) * (t - Math.floor(t)));
|
|
2762
3765
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
3766
|
+
return out;
|
|
3767
|
+
}
|
|
3768
|
+
function buildSeries(total, n, jitterSeed = 1) {
|
|
3769
|
+
const shape = shapeFor(n);
|
|
3770
|
+
const j = shape.map((v, i) => v * (0.82 + 0.36 * Math.abs(Math.sin(i * 12.9898 * jitterSeed))));
|
|
3771
|
+
const s = j.reduce((a, b) => a + b, 0);
|
|
3772
|
+
return j.map((v) => (v / s) * total);
|
|
3773
|
+
}
|
|
3774
|
+
function seriesForRange(range) {
|
|
3775
|
+
const { cost } = kpisForRange(range);
|
|
3776
|
+
const n = RANGES[range].points;
|
|
3777
|
+
return { current: buildSeries(cost, n, 1), previous: buildSeries(cost / 1.12, n, 1.7), n };
|
|
3778
|
+
}
|
|
3779
|
+
const FEATURES = ['chat', 'rag-search', 'summarize', 'classify', 'agent-loop', 'embeddings', 'code-review', 'extract'];
|
|
3780
|
+
const FEATURE_COLOR = {
|
|
3781
|
+
'chat': '#58a6ff', 'rag-search': '#3fb950', 'summarize': '#bc8cff', 'classify': '#e3b341',
|
|
3782
|
+
'agent-loop': '#f778ba', 'embeddings': '#56d4dd', 'code-review': '#ff7b72', 'extract': '#7ee787',
|
|
3783
|
+
};
|
|
3784
|
+
let __callSeq = 48213;
|
|
3785
|
+
function rng(seed) { let x = Math.sin(seed) * 10000; return x - Math.floor(x); }
|
|
3786
|
+
function makeCall(secondsAgo, seed) {
|
|
3787
|
+
const m = BASE_MODELS[Math.floor(rng(seed) * BASE_MODELS.length)];
|
|
3788
|
+
const feat = FEATURES[Math.floor(rng(seed * 1.3) * FEATURES.length)];
|
|
3789
|
+
const inTok = Math.round(800 + rng(seed * 2.1) * 14000);
|
|
3790
|
+
const outTok = Math.round(60 + rng(seed * 3.7) * 2200);
|
|
3791
|
+
const cost = (inTok / 1e6) * 0.4 + (outTok / 1e6) * 3.2;
|
|
3792
|
+
const r = rng(seed * 5.9);
|
|
3793
|
+
const status = r > 0.965 ? 'error' : r > 0.9 ? 'slow' : 'ok';
|
|
3794
|
+
return {
|
|
3795
|
+
id: ++__callSeq, secondsAgo, ts: Date.now() - secondsAgo * 1000,
|
|
3796
|
+
model: m.id, modelColor: m.color,
|
|
3797
|
+
session: 'sess_' + (seed * 7919 % 1e6 | 0).toString(36).padStart(4, '0'),
|
|
3798
|
+
feature: feat, featureColor: FEATURE_COLOR[feat],
|
|
3799
|
+
inTok, outTok, cost, latency: Math.round(m.latency * (0.6 + rng(seed * 8.3) * 1.2)), status,
|
|
3800
|
+
};
|
|
3801
|
+
}
|
|
3802
|
+
function seedFeed(count) {
|
|
3803
|
+
const arr = [];
|
|
3804
|
+
for (let i = 0; i < count; i++) arr.push(makeCall(2 + i * 7 + Math.floor(rng(i * 3.3) * 6), 100 + i));
|
|
3805
|
+
return arr;
|
|
3806
|
+
}
|
|
3807
|
+
function callsForModel(modelId, count = 5) {
|
|
3808
|
+
const arr = [];
|
|
3809
|
+
let sa = 12;
|
|
3810
|
+
for (let i = 0; i < count; i++) {
|
|
3811
|
+
const c = makeCall(sa, 900 + i + modelId.length);
|
|
3812
|
+
c.model = modelId;
|
|
3813
|
+
c.modelColor = (BASE_MODELS.find((m) => m.id === modelId) || {}).color || '#58a6ff';
|
|
3814
|
+
arr.push(c);
|
|
3815
|
+
sa += 40 + Math.floor(rng(i * 2.2) * 220);
|
|
2769
3816
|
}
|
|
3817
|
+
return arr;
|
|
3818
|
+
}
|
|
3819
|
+
const BUDGET = { used: 45.0, limit: 100.0, daysLeft: 18, cycleDays: 30 };
|
|
2770
3820
|
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3821
|
+
Object.assign(window, {
|
|
3822
|
+
TW: {
|
|
3823
|
+
fmtUSD, fmtMoney, fmtInt, fmtCompact, fmtAgo,
|
|
3824
|
+
BASE_MODELS, RANGES, modelsForRange, kpisForRange,
|
|
3825
|
+
seriesForRange, seedFeed, makeCall, callsForModel,
|
|
3826
|
+
FEATURES, FEATURE_COLOR, BUDGET, _buildSeries: buildSeries,
|
|
3827
|
+
},
|
|
3828
|
+
});
|
|
2774
3829
|
|
|
2775
|
-
|
|
2776
|
-
|
|
3830
|
+
// SSE overlay \u2014 patches window.TW functions when real data arrives
|
|
3831
|
+
(function () {
|
|
3832
|
+
var MC = ['#bc8cff','#3fb950','#58a6ff','#f778ba','#e3b341','#56d4dd','#79c0ff','#ffa657','#ff7b72','#a5d6ff'];
|
|
3833
|
+
function guessProv(id) {
|
|
3834
|
+
if (/claude/i.test(id)) return 'Anthropic';
|
|
3835
|
+
if (/gpt|o1|o3|o4/i.test(id)) return 'OpenAI';
|
|
3836
|
+
if (/gemini/i.test(id)) return 'Google';
|
|
3837
|
+
return 'Other';
|
|
2777
3838
|
}
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
3839
|
+
function buildRealModels(byModel) {
|
|
3840
|
+
return Object.entries(byModel).map(function(e, i) {
|
|
3841
|
+
var id = e[0], s = e[1];
|
|
3842
|
+
return { id: id, provider: guessProv(id), color: MC[i % MC.length],
|
|
3843
|
+
calls: s.calls || 0, inTok: (s.tokens && s.tokens.input) || 0,
|
|
3844
|
+
outTok: (s.tokens && s.tokens.output) || 0, cost: s.costUSD || 0, latency: 1200 };
|
|
3845
|
+
}).sort(function(a, b) { return b.cost - a.cost; });
|
|
2781
3846
|
}
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
});
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
if (evtSource) { evtSource.close(); evtSource = null; }
|
|
2796
|
-
evtSource = new EventSource('/events?filter=' + encodeURIComponent(activeFilter));
|
|
2797
|
-
evtSource.onmessage = function(e) {
|
|
2798
|
-
try { updateUI(JSON.parse(e.data)); } catch (_) {}
|
|
3847
|
+
function applySSEData(data) {
|
|
3848
|
+
var r = data.report, fc = data.forecast, ts = data.timeSeries || [];
|
|
3849
|
+
if (!r || !r.byModel || Object.keys(r.byModel).length === 0) return;
|
|
3850
|
+
var mods = buildRealModels(r.byModel);
|
|
3851
|
+
var totalCalls = mods.reduce(function(s, m) { return s + m.calls; }, 0);
|
|
3852
|
+
var totalCost = r.totalCostUSD || 0;
|
|
3853
|
+
var totalIn = 0, totalOut = 0;
|
|
3854
|
+
if (r.totalTokens) { totalIn = r.totalTokens.input || 0; totalOut = r.totalTokens.output || 0; }
|
|
3855
|
+
else { mods.forEach(function(m) { totalIn += m.inTok; totalOut += m.outTok; }); }
|
|
3856
|
+
var costs = ts.map(function(b) { return b.cost || 0; });
|
|
3857
|
+
window.TW.kpisForRange = function() {
|
|
3858
|
+
return { cost: totalCost, calls: totalCalls, inTok: totalIn, outTok: totalOut,
|
|
3859
|
+
models: mods, burnHr: (fc && fc.burnRatePerHour) || 0 };
|
|
2799
3860
|
};
|
|
3861
|
+
if (costs.length >= 2) {
|
|
3862
|
+
window.TW.seriesForRange = function() {
|
|
3863
|
+
return { current: costs, previous: buildSeries(totalCost / 1.12, costs.length, 1.7), n: costs.length };
|
|
3864
|
+
};
|
|
3865
|
+
}
|
|
3866
|
+
if (fc && fc.projectedDailyCostUSD) window.TW.BUDGET.daily = fc.projectedDailyCostUSD;
|
|
3867
|
+
if (fc && fc.burnRatePerHour) {
|
|
3868
|
+
var elapsed = window.TW.BUDGET.cycleDays - window.TW.BUDGET.daysLeft;
|
|
3869
|
+
window.TW.BUDGET.used = fc.burnRatePerHour * 24 * Math.max(elapsed, 1);
|
|
3870
|
+
}
|
|
3871
|
+
window.dispatchEvent(new CustomEvent('tw-data-update'));
|
|
3872
|
+
}
|
|
3873
|
+
var evtSource = null;
|
|
3874
|
+
function connect(filter) {
|
|
3875
|
+
if (evtSource) { try { evtSource.close(); } catch(e) {} }
|
|
3876
|
+
var f = filter === 'All' ? 'all' : filter;
|
|
3877
|
+
evtSource = new EventSource('/events?filter=' + encodeURIComponent(f));
|
|
3878
|
+
evtSource.onmessage = function(e) { try { applySSEData(JSON.parse(e.data)); } catch(_) {} };
|
|
2800
3879
|
evtSource.onerror = function() {
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
evtSource.onopen = function() {
|
|
2804
|
-
document.getElementById('live-label').textContent = 'live';
|
|
3880
|
+
try { evtSource.close(); } catch(e) {}
|
|
3881
|
+
setTimeout(function() { connect(f); }, 5000);
|
|
2805
3882
|
};
|
|
2806
3883
|
}
|
|
3884
|
+
window.__twSSEConnect = connect;
|
|
3885
|
+
connect('24h');
|
|
3886
|
+
})();
|
|
3887
|
+
</script>
|
|
2807
3888
|
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
const fc = data.forecast;
|
|
2812
|
-
const ts = data.timeSeries;
|
|
2813
|
-
|
|
2814
|
-
// Cards
|
|
2815
|
-
document.getElementById('card-cost').textContent = fmtUSD(r.totalCostUSD);
|
|
2816
|
-
document.getElementById('card-input').textContent = fmtNum(r.totalTokens.input);
|
|
2817
|
-
document.getElementById('card-output').textContent = fmtNum(r.totalTokens.output);
|
|
2818
|
-
document.getElementById('card-calls').textContent = fmtNum(totalCalls(r.byModel));
|
|
2819
|
-
document.getElementById('card-burn').textContent = fmtUSD(fc.burnRatePerHour);
|
|
2820
|
-
document.getElementById('card-period').textContent =
|
|
2821
|
-
r.period.from !== r.period.to
|
|
2822
|
-
? fmtDate(r.period.from) + ' \u2013 ' + fmtDate(r.period.to)
|
|
2823
|
-
: '';
|
|
3889
|
+
<script type="text/babel">
|
|
3890
|
+
// tw-app.jsx
|
|
3891
|
+
const { useState: useStateApp, useEffect: useEffectApp, useMemo: useMemoApp } = React;
|
|
2824
3892
|
|
|
2825
|
-
// Line chart
|
|
2826
|
-
const labels = ts.map(function(b) {
|
|
2827
|
-
try { return new Date(b.bucket).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
|
2828
|
-
catch { return b.bucket; }
|
|
2829
|
-
});
|
|
2830
|
-
const costs = ts.map(function(b) { return b.cost; });
|
|
2831
3893
|
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
3894
|
+
function LoadingSkeleton() {
|
|
3895
|
+
return (
|
|
3896
|
+
<div className="tw-skel-wrap">
|
|
3897
|
+
<div className="tw-skel" style={{ height: 56 }} />
|
|
3898
|
+
<div className="tw-kpis">{[0,0,0,0,0].map((_,i) => <div key={i} className="tw-skel" style={{ height: 96 }} />)}</div>
|
|
3899
|
+
<div className="tw-charts">
|
|
3900
|
+
<div className="tw-skel tw-chart-main" style={{ height: 320 }} />
|
|
3901
|
+
<div className="tw-skel tw-chart-side" style={{ height: 320 }} />
|
|
3902
|
+
</div>
|
|
3903
|
+
<div className="tw-skel" style={{ height: 260 }} />
|
|
3904
|
+
</div>
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
function EmptyState() {
|
|
3908
|
+
return (
|
|
3909
|
+
<div className="tw-empty">
|
|
3910
|
+
<div className="tw-empty-art">
|
|
3911
|
+
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
|
|
3912
|
+
<rect x="8" y="14" width="48" height="36" rx="4" stroke="#30363d" strokeWidth="2" />
|
|
3913
|
+
<path d="M16 40l8-9 7 6 9-13 8 10" stroke="#484f58" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
3914
|
+
<circle cx="24" cy="31" r="2" fill="#484f58" />
|
|
3915
|
+
</svg>
|
|
3916
|
+
</div>
|
|
3917
|
+
<h2>No usage yet</h2>
|
|
3918
|
+
<p>Once your app starts making LLM calls through tokenwatch, cost & token metrics show up here in real time.</p>
|
|
3919
|
+
<div className="tw-empty-actions">
|
|
3920
|
+
<button className="tw-btn-primary">View integration guide <Ico.arrow /></button>
|
|
3921
|
+
<button className="tw-btn-2">Copy API key</button>
|
|
3922
|
+
</div>
|
|
3923
|
+
<div className="tw-empty-snippet">
|
|
3924
|
+
<span className="tw-snip-c">import</span> TokenWatch <span className="tw-snip-c">from</span> <span className="tw-snip-s">'@diogonzafe/tokenwatch'</span>
|
|
3925
|
+
</div>
|
|
3926
|
+
</div>
|
|
3927
|
+
);
|
|
3928
|
+
}
|
|
3929
|
+
function ChartsRow({ range, models, kpis, series, t }) {
|
|
3930
|
+
const { fmtUSD, RANGES } = window.TW;
|
|
3931
|
+
const [activeSlice, setActiveSlice] = useStateApp(null);
|
|
3932
|
+
const animate = t.animLevel !== 'minimal';
|
|
3933
|
+
const rangeConfig = RANGES[range] || RANGES['24h'];
|
|
3934
|
+
const safeTotal = kpis.cost || 0.0001;
|
|
3935
|
+
return (
|
|
3936
|
+
<div className="tw-charts">
|
|
3937
|
+
<div className="tw-card tw-chart-main">
|
|
3938
|
+
<div className="tw-sec-head">
|
|
3939
|
+
<h3>Cost over time</h3>
|
|
3940
|
+
<div className="tw-chart-legend">
|
|
3941
|
+
<span><i style={{ background: t.accent }} /> current</span>
|
|
3942
|
+
{t.compareMode && series.previous && <span className="muted"><i className="dash" /> previous</span>}
|
|
3943
|
+
<span className="tw-sec-sub">· per {rangeConfig.step}</span>
|
|
3944
|
+
</div>
|
|
3945
|
+
</div>
|
|
3946
|
+
<LineChart current={series.current} previous={t.compareMode ? series.previous : null}
|
|
3947
|
+
n={series.n} color={t.accent} compare={t.compareMode && !!series.previous}
|
|
3948
|
+
animate={animate} fmt={(v) => fmtUSD(v, 4)} />
|
|
3949
|
+
</div>
|
|
3950
|
+
<div className="tw-card tw-chart-side">
|
|
3951
|
+
<div className="tw-sec-head"><h3>By model</h3></div>
|
|
3952
|
+
<div className="tw-doughnut-wrap">
|
|
3953
|
+
<Doughnut data={models} total={safeTotal} fmt={(v) => fmtUSD(v, 4)} active={activeSlice} onHover={setActiveSlice} />
|
|
3954
|
+
</div>
|
|
3955
|
+
<div className="tw-legend">
|
|
3956
|
+
{[...models].sort((a, b) => b.cost - a.cost).map((m) => (
|
|
3957
|
+
<div key={m.id} className={'tw-legend-row' + (activeSlice === m.id ? ' on' : '')}
|
|
3958
|
+
onMouseEnter={() => setActiveSlice(m.id)} onMouseLeave={() => setActiveSlice(null)}>
|
|
3959
|
+
<span className="tw-model-dot" style={{ background: m.color }} />
|
|
3960
|
+
<span className="tw-legend-name">{m.id}</span>
|
|
3961
|
+
<span className="tw-legend-val">{fmtUSD(m.cost, 4)}</span>
|
|
3962
|
+
<span className="tw-legend-pct">{((m.cost / safeTotal) * 100).toFixed(0)}%</span>
|
|
3963
|
+
</div>
|
|
3964
|
+
))}
|
|
3965
|
+
</div>
|
|
3966
|
+
</div>
|
|
3967
|
+
</div>
|
|
3968
|
+
);
|
|
3969
|
+
}
|
|
3970
|
+
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
3971
|
+
"density": "compact",
|
|
3972
|
+
"kpiSparklines": true,
|
|
3973
|
+
"smartHighlight": true,
|
|
3974
|
+
"compareMode": false,
|
|
3975
|
+
"tableSort": true,
|
|
3976
|
+
"budgetAlerts": true,
|
|
3977
|
+
"forecastScenario": 20,
|
|
3978
|
+
"liveFeed": true,
|
|
3979
|
+
"animLevel": "lively",
|
|
3980
|
+
"commandPalette": true,
|
|
3981
|
+
"appState": "data",
|
|
3982
|
+
"accent": "#58a6ff"
|
|
3983
|
+
}/*EDITMODE-END*/;
|
|
3984
|
+
function App() {
|
|
3985
|
+
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
3986
|
+
const [range, setRange] = useStateApp('24h');
|
|
3987
|
+
const [selModel, setSelModel] = useStateApp(null);
|
|
3988
|
+
const [paletteOpen, setPaletteOpen] = useStateApp(false);
|
|
3989
|
+
const [_sseV, setSseV] = useStateApp(0);
|
|
2870
3990
|
|
|
2871
|
-
|
|
2872
|
-
const
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3991
|
+
useEffectApp(() => {
|
|
3992
|
+
const h = function() { setSseV(function(v) { return v + 1; }); };
|
|
3993
|
+
window.addEventListener('tw-data-update', h);
|
|
3994
|
+
return function() { window.removeEventListener('tw-data-update', h); };
|
|
3995
|
+
}, []);
|
|
2876
3996
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
type: 'doughnut',
|
|
2881
|
-
data: { labels: dLabels, datasets: [{ data: dData, backgroundColor: dColors, borderWidth: 0, hoverOffset: 4 }] },
|
|
2882
|
-
options: {
|
|
2883
|
-
responsive: true, maintainAspectRatio: false,
|
|
2884
|
-
cutout: '65%',
|
|
2885
|
-
plugins: {
|
|
2886
|
-
legend: {
|
|
2887
|
-
position: 'bottom',
|
|
2888
|
-
labels: { color: '#8b949e', boxWidth: 10, padding: 12, font: { size: 11 } },
|
|
2889
|
-
},
|
|
2890
|
-
},
|
|
2891
|
-
},
|
|
2892
|
-
});
|
|
2893
|
-
} else {
|
|
2894
|
-
doughnutChart.data.labels = dLabels;
|
|
2895
|
-
doughnutChart.data.datasets[0].data = dData;
|
|
2896
|
-
doughnutChart.data.datasets[0].backgroundColor = dColors;
|
|
2897
|
-
doughnutChart.update('none');
|
|
2898
|
-
}
|
|
3997
|
+
useEffectApp(() => {
|
|
3998
|
+
if (window.__twSSEConnect) window.__twSSEConnect(range);
|
|
3999
|
+
}, [range]);
|
|
2899
4000
|
|
|
2900
|
-
|
|
2901
|
-
const
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
} else {
|
|
2905
|
-
const totalCost = r.totalCostUSD || 1;
|
|
2906
|
-
let html = '<table><thead><tr>' +
|
|
2907
|
-
'<th>Model</th>' +
|
|
2908
|
-
'<th class="num">Calls</th>' +
|
|
2909
|
-
'<th class="num">In tokens</th>' +
|
|
2910
|
-
'<th class="num">Out tokens</th>' +
|
|
2911
|
-
'<th class="num">Cost</th>' +
|
|
2912
|
-
'<th class="num">Share</th>' +
|
|
2913
|
-
'</tr></thead><tbody>';
|
|
2914
|
-
const sorted = modelEntries.slice().sort(function(a, b) { return b[1].costUSD - a[1].costUSD; });
|
|
2915
|
-
sorted.forEach(function(entry) {
|
|
2916
|
-
const name = entry[0]; const m = entry[1];
|
|
2917
|
-
const pct = (m.costUSD / totalCost * 100).toFixed(1);
|
|
2918
|
-
const barW = Math.round(m.costUSD / totalCost * 80);
|
|
2919
|
-
html += '<tr>' +
|
|
2920
|
-
'<td>' + escHtml(name) + '</td>' +
|
|
2921
|
-
'<td class="num">' + fmtNum(m.calls) + '</td>' +
|
|
2922
|
-
'<td class="num">' + fmtNum(m.tokens.input) + '</td>' +
|
|
2923
|
-
'<td class="num">' + fmtNum(m.tokens.output) + '</td>' +
|
|
2924
|
-
'<td class="num">' + fmtUSD(m.costUSD) + '</td>' +
|
|
2925
|
-
'<td class="num">' + pct + '%' +
|
|
2926
|
-
'<span class="bar-wrap"><span class="bar-fill" style="width:' + barW + 'px"></span></span>' +
|
|
2927
|
-
'</td></tr>';
|
|
2928
|
-
});
|
|
2929
|
-
html += '</tbody></table>';
|
|
2930
|
-
modelWrap.innerHTML = html;
|
|
2931
|
-
}
|
|
4001
|
+
const kpis = useMemoApp(() => {
|
|
4002
|
+
const { kpisForRange } = window.TW;
|
|
4003
|
+
return kpisForRange(range);
|
|
4004
|
+
}, [range, _sseV]);
|
|
2932
4005
|
|
|
2933
|
-
|
|
2934
|
-
const
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
if (userEntries.length > 0) {
|
|
2938
|
-
let html = '<table><thead><tr><th>User</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2939
|
-
userEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2940
|
-
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2941
|
-
});
|
|
2942
|
-
html += '</tbody></table>';
|
|
2943
|
-
document.getElementById('user-table-wrap').innerHTML = html;
|
|
2944
|
-
}
|
|
4006
|
+
const series = useMemoApp(() => {
|
|
4007
|
+
const { seriesForRange } = window.TW;
|
|
4008
|
+
return seriesForRange(range);
|
|
4009
|
+
}, [range, _sseV]);
|
|
2945
4010
|
|
|
2946
|
-
|
|
2947
|
-
const featureEntries = Object.entries(r.byFeature);
|
|
2948
|
-
const featuresSection = document.getElementById('features-section');
|
|
2949
|
-
featuresSection.style.display = featureEntries.length === 0 ? 'none' : '';
|
|
2950
|
-
if (featureEntries.length > 0) {
|
|
2951
|
-
let html = '<table><thead><tr><th>Feature</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2952
|
-
featureEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2953
|
-
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2954
|
-
});
|
|
2955
|
-
html += '</tbody></table>';
|
|
2956
|
-
document.getElementById('feature-table-wrap').innerHTML = html;
|
|
2957
|
-
}
|
|
4011
|
+
const models = kpis.models;
|
|
2958
4012
|
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
4013
|
+
useEffectApp(() => {
|
|
4014
|
+
const onKey = (e) => {
|
|
4015
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
4016
|
+
e.preventDefault();
|
|
4017
|
+
if (t.commandPalette) setPaletteOpen((v) => !v);
|
|
4018
|
+
}
|
|
4019
|
+
};
|
|
4020
|
+
window.addEventListener('keydown', onKey);
|
|
4021
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
4022
|
+
}, [t.commandPalette]);
|
|
2965
4023
|
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
4024
|
+
const handleAction = (act) => {
|
|
4025
|
+
if (!act) return;
|
|
4026
|
+
if (act.range) setRange(act.range);
|
|
4027
|
+
if (act.model) {
|
|
4028
|
+
const m = models.find((x) => x.id === act.model);
|
|
4029
|
+
if (m) setSelModel({ ...m, share: m.cost / Math.max(kpis.cost, 0.0001) });
|
|
4030
|
+
}
|
|
4031
|
+
};
|
|
2970
4032
|
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
})()
|
|
4033
|
+
return (
|
|
4034
|
+
<div className="tw-root" style={{ '--accent': t.accent }}>
|
|
4035
|
+
<Header t={t} onOpenPalette={() => setPaletteOpen(true)} />
|
|
4036
|
+
{t.appState === 'loading' ? (
|
|
4037
|
+
<main className="tw-main"><LoadingSkeleton /></main>
|
|
4038
|
+
) : t.appState === 'empty' ? (
|
|
4039
|
+
<main className="tw-main"><EmptyState /></main>
|
|
4040
|
+
) : (
|
|
4041
|
+
<main className="tw-main">
|
|
4042
|
+
<BudgetBar t={t} />
|
|
4043
|
+
<KpiRow kpis={kpis} range={range} t={t} />
|
|
4044
|
+
<TimeFilter range={range} setRange={setRange} t={t} setTweak={setTweak} />
|
|
4045
|
+
<ChartsRow range={range} models={models} kpis={kpis} series={series} t={t} />
|
|
4046
|
+
<ModelTable models={models} total={kpis.cost} t={t} exampleHover={t.smartHighlight}
|
|
4047
|
+
onRowClick={(m) => setSelModel({ ...m, share: m.cost / Math.max(kpis.cost, 0.0001) })} />
|
|
4048
|
+
<ForecastSection t={t} />
|
|
4049
|
+
<LiveActivity t={t} />
|
|
4050
|
+
</main>
|
|
4051
|
+
)}
|
|
4052
|
+
<SlideOver model={selModel} onClose={() => setSelModel(null)} t={t} />
|
|
4053
|
+
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} onAction={handleAction} />
|
|
4054
|
+
<TweaksPanel title="Tweaks \xB7 UX">
|
|
4055
|
+
<TweakSection label="Hierarquia & densidade" />
|
|
4056
|
+
<TweakRadio label="Densidade" value={t.density} options={[{ value: 'compact', label: 'Compacto' }, { value: 'comfy', label: 'Confort.' }]} onChange={(v) => setTweak('density', v)} />
|
|
4057
|
+
<TweakToggle label="Sparklines nos KPIs" value={t.kpiSparklines} onChange={(v) => setTweak('kpiSparklines', v)} />
|
|
4058
|
+
<TweakToggle label="Smart highlight" value={t.smartHighlight} onChange={(v) => setTweak('smartHighlight', v)} />
|
|
4059
|
+
<TweakSection label="An\xE1lise" />
|
|
4060
|
+
<TweakToggle label="Comparar c/ per\xEDodo anterior" value={t.compareMode} onChange={(v) => setTweak('compareMode', v)} />
|
|
4061
|
+
<TweakToggle label="Sorting + a\xE7\xF5es na tabela" value={t.tableSort} onChange={(v) => setTweak('tableSort', v)} />
|
|
4062
|
+
<TweakSection label="Custo proativo" />
|
|
4063
|
+
<TweakToggle label="Alertas de budget" value={t.budgetAlerts} onChange={(v) => setTweak('budgetAlerts', v)} />
|
|
4064
|
+
<TweakSlider label="Cen\xE1rio: crescimento" value={t.forecastScenario} min={0} max={300} step={5} unit="%" onChange={(v) => setTweak('forecastScenario', v)} />
|
|
4065
|
+
<TweakSection label="Tempo real" />
|
|
4066
|
+
<TweakToggle label="Live feed" value={t.liveFeed} onChange={(v) => setTweak('liveFeed', v)} />
|
|
4067
|
+
<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)} />
|
|
4068
|
+
<TweakSection label="Navega\xE7\xE3o & estado" />
|
|
4069
|
+
<TweakToggle label="Command palette (\u2318K)" value={t.commandPalette} onChange={(v) => setTweak('commandPalette', v)} />
|
|
4070
|
+
<TweakRadio label="Estado" value={t.appState} options={[{ value: 'data', label: 'Dados' }, { value: 'loading', label: 'Load' }, { value: 'empty', label: 'Vazio' }]} onChange={(v) => setTweak('appState', v)} />
|
|
4071
|
+
<TweakSection label="Visual" />
|
|
4072
|
+
<TweakColor label="Accent" value={t.accent} options={['#58a6ff', '#3fb950', '#bc8cff', '#f778ba']} onChange={(v) => setTweak('accent', v)} />
|
|
4073
|
+
</TweaksPanel>
|
|
4074
|
+
</div>
|
|
4075
|
+
);
|
|
4076
|
+
}
|
|
4077
|
+
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
|
|
2974
4078
|
</script>
|
|
2975
4079
|
</body>
|
|
2976
4080
|
</html>`;
|