@diogonzafe/tokenwatch 0.4.0 → 0.6.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 +132 -6
- package/dist/adapters.d.cts +1 -1
- package/dist/adapters.d.ts +1 -1
- package/dist/cli.js +848 -8
- package/dist/cli.js.map +1 -1
- package/dist/exporters.cjs +76 -0
- package/dist/exporters.cjs.map +1 -0
- package/dist/exporters.d.cts +60 -0
- package/dist/exporters.d.ts +60 -0
- package/dist/exporters.js +56 -0
- package/dist/exporters.js.map +1 -0
- package/dist/{index-CJKk1hHw.d.cts → index-D9xq0RNg.d.cts} +19 -1
- package/dist/{index-CJKk1hHw.d.ts → index-D9xq0RNg.d.ts} +19 -1
- package/dist/index.cjs +63 -3
- 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 +63 -3
- package/dist/index.js.map +1 -1
- package/dist/langchain.d.cts +1 -1
- package/dist/langchain.d.ts +1 -1
- package/package.json +13 -3
- package/prices.json +1 -1
- package/dist/cli.d.ts +0 -1
package/dist/cli.js
CHANGED
|
@@ -245,7 +245,7 @@ function maybeSuggestCheaperModel(model, costUSD, inputTokens, outputTokens, lay
|
|
|
245
245
|
|
|
246
246
|
// prices.json
|
|
247
247
|
var prices_default = {
|
|
248
|
-
updated_at: "2026-04-
|
|
248
|
+
updated_at: "2026-04-23",
|
|
249
249
|
source: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
|
|
250
250
|
models: {
|
|
251
251
|
"gpt-4o": {
|
|
@@ -1594,7 +1594,14 @@ var TrackerConfigSchema = z.object({
|
|
|
1594
1594
|
perUser: BudgetConfigSchema.optional(),
|
|
1595
1595
|
perSession: BudgetConfigSchema.optional()
|
|
1596
1596
|
}).optional(),
|
|
1597
|
-
suggestions: z.boolean().optional().default(false)
|
|
1597
|
+
suggestions: z.boolean().optional().default(false),
|
|
1598
|
+
anomalyDetection: z.object({
|
|
1599
|
+
multiplierThreshold: z.number().positive(),
|
|
1600
|
+
webhookUrl: z.string().url(),
|
|
1601
|
+
windowHours: z.number().positive().optional().default(24),
|
|
1602
|
+
mode: z.enum(["once", "always"]).optional().default("once")
|
|
1603
|
+
}).optional(),
|
|
1604
|
+
exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional()
|
|
1598
1605
|
});
|
|
1599
1606
|
function createTracker(config = {}) {
|
|
1600
1607
|
const parsed = TrackerConfigSchema.safeParse(config);
|
|
@@ -1611,7 +1618,9 @@ ${issues}`);
|
|
|
1611
1618
|
customPrices,
|
|
1612
1619
|
warnIfStaleAfterHours,
|
|
1613
1620
|
budgets,
|
|
1614
|
-
suggestions
|
|
1621
|
+
suggestions,
|
|
1622
|
+
anomalyDetection,
|
|
1623
|
+
exporter
|
|
1615
1624
|
} = parsed.data;
|
|
1616
1625
|
const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
|
|
1617
1626
|
let remotePrices;
|
|
@@ -1644,6 +1653,7 @@ ${issues}`);
|
|
|
1644
1653
|
let alertFired = false;
|
|
1645
1654
|
const firedUserAlerts = /* @__PURE__ */ new Set();
|
|
1646
1655
|
const firedSessionAlerts = /* @__PURE__ */ new Set();
|
|
1656
|
+
const firedAnomalyKeys = /* @__PURE__ */ new Set();
|
|
1647
1657
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1648
1658
|
function resolveModelPrice(model) {
|
|
1649
1659
|
maybeWarnStaleness();
|
|
@@ -1668,7 +1678,12 @@ ${issues}`);
|
|
|
1668
1678
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1669
1679
|
};
|
|
1670
1680
|
storage.record(full);
|
|
1681
|
+
if (exporter) {
|
|
1682
|
+
Promise.resolve(exporter.export(full)).catch(() => {
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1671
1685
|
maybeFireAlerts(full);
|
|
1686
|
+
if (anomalyDetection) maybeDetectAnomaly(full);
|
|
1672
1687
|
if (suggestions) {
|
|
1673
1688
|
maybeSuggestCheaperModel(entry.model, costUSD, entry.inputTokens, entry.outputTokens, {
|
|
1674
1689
|
bundledPrices,
|
|
@@ -1838,11 +1853,56 @@ ${issues}`);
|
|
|
1838
1853
|
basedOnPeriod: { from: first, to: last }
|
|
1839
1854
|
};
|
|
1840
1855
|
}
|
|
1856
|
+
function maybeDetectAnomaly(entry) {
|
|
1857
|
+
if (entry.costUSD <= 0) return;
|
|
1858
|
+
const { multiplierThreshold, webhookUrl: aUrl, windowHours: wh, mode: modeRaw } = anomalyDetection;
|
|
1859
|
+
const wHours = wh ?? 24;
|
|
1860
|
+
const mode = modeRaw ?? "once";
|
|
1861
|
+
const windowStart = Date.now() - wHours * 60 * 60 * 1e3;
|
|
1862
|
+
const entryTs = new Date(entry.timestamp).getTime();
|
|
1863
|
+
function checkEntity(key, label, predicate) {
|
|
1864
|
+
if (mode !== "always" && firedAnomalyKeys.has(key)) return;
|
|
1865
|
+
if (mode !== "always") firedAnomalyKeys.add(key);
|
|
1866
|
+
Promise.resolve(storage.getAll()).then((all) => {
|
|
1867
|
+
const history = all.filter(
|
|
1868
|
+
(e) => predicate(e) && new Date(e.timestamp).getTime() >= windowStart && new Date(e.timestamp).getTime() !== entryTs
|
|
1869
|
+
);
|
|
1870
|
+
if (history.length === 0) {
|
|
1871
|
+
if (mode !== "always") firedAnomalyKeys.delete(key);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const avg = history.reduce((s, e) => s + e.costUSD, 0) / history.length;
|
|
1875
|
+
if (avg <= 0 || entry.costUSD <= avg * multiplierThreshold) {
|
|
1876
|
+
if (mode !== "always") firedAnomalyKeys.delete(key);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const multiple = (entry.costUSD / avg).toFixed(1);
|
|
1880
|
+
fireWebhook(aUrl, {
|
|
1881
|
+
text: `[tokenwatch] Anomaly: ${label} call cost $${entry.costUSD.toFixed(4)} is ${multiple}x above ${wHours}h average ($${avg.toFixed(4)})`
|
|
1882
|
+
});
|
|
1883
|
+
}).catch(() => {
|
|
1884
|
+
if (mode !== "always") firedAnomalyKeys.delete(key);
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
if (entry.userId) {
|
|
1888
|
+
checkEntity(
|
|
1889
|
+
`user:${entry.userId}`,
|
|
1890
|
+
`user "${entry.userId}"`,
|
|
1891
|
+
(e) => e.userId === entry.userId
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
checkEntity(
|
|
1895
|
+
`model:${entry.model}`,
|
|
1896
|
+
`model "${entry.model}"`,
|
|
1897
|
+
(e) => e.model === entry.model
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1841
1900
|
async function reset() {
|
|
1842
1901
|
await Promise.resolve(storage.clearAll());
|
|
1843
1902
|
alertFired = false;
|
|
1844
1903
|
firedUserAlerts.clear();
|
|
1845
1904
|
firedSessionAlerts.clear();
|
|
1905
|
+
firedAnomalyKeys.clear();
|
|
1846
1906
|
}
|
|
1847
1907
|
async function resetSession(sessionId) {
|
|
1848
1908
|
await Promise.resolve(storage.clearSession(sessionId));
|
|
@@ -1926,6 +1986,764 @@ function csvEscape(value) {
|
|
|
1926
1986
|
return value;
|
|
1927
1987
|
}
|
|
1928
1988
|
|
|
1989
|
+
// src/dashboard/server.ts
|
|
1990
|
+
import { createServer } from "http";
|
|
1991
|
+
|
|
1992
|
+
// src/dashboard/data.ts
|
|
1993
|
+
function parseSince(filter) {
|
|
1994
|
+
if (!filter || filter === "all") return void 0;
|
|
1995
|
+
const now = Date.now();
|
|
1996
|
+
switch (filter) {
|
|
1997
|
+
case "1h":
|
|
1998
|
+
return now - 60 * 60 * 1e3;
|
|
1999
|
+
case "24h":
|
|
2000
|
+
return now - 24 * 60 * 60 * 1e3;
|
|
2001
|
+
case "7d":
|
|
2002
|
+
return now - 7 * 24 * 60 * 60 * 1e3;
|
|
2003
|
+
case "30d":
|
|
2004
|
+
return now - 30 * 24 * 60 * 60 * 1e3;
|
|
2005
|
+
default:
|
|
2006
|
+
return void 0;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function buildTimeSeries(entries, sinceMs) {
|
|
2010
|
+
const now = Date.now();
|
|
2011
|
+
const windowMs = sinceMs !== void 0 ? now - sinceMs : void 0;
|
|
2012
|
+
let bucketMs;
|
|
2013
|
+
if (windowMs !== void 0 && windowMs <= 60 * 60 * 1e3) {
|
|
2014
|
+
bucketMs = 5 * 60 * 1e3;
|
|
2015
|
+
} else if (windowMs !== void 0 && windowMs <= 24 * 60 * 60 * 1e3) {
|
|
2016
|
+
bucketMs = 60 * 60 * 1e3;
|
|
2017
|
+
} else {
|
|
2018
|
+
bucketMs = 24 * 60 * 60 * 1e3;
|
|
2019
|
+
}
|
|
2020
|
+
const filtered = sinceMs !== void 0 ? entries.filter((e) => new Date(e.timestamp).getTime() >= sinceMs) : entries;
|
|
2021
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2022
|
+
for (const entry of filtered) {
|
|
2023
|
+
const ts = new Date(entry.timestamp).getTime();
|
|
2024
|
+
const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
|
|
2025
|
+
const bucketKey = new Date(bucketTs).toISOString();
|
|
2026
|
+
const existing = buckets.get(bucketKey);
|
|
2027
|
+
if (existing) {
|
|
2028
|
+
existing.cost += entry.costUSD;
|
|
2029
|
+
existing.calls += 1;
|
|
2030
|
+
} else {
|
|
2031
|
+
buckets.set(bucketKey, { bucket: bucketKey, cost: entry.costUSD, calls: 1 });
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return Array.from(buckets.values()).sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
2035
|
+
}
|
|
2036
|
+
function getFingerprint(data) {
|
|
2037
|
+
return `${data.report.totalCostUSD.toFixed(8)}-${data.report.totalTokens.input}-${data.timeSeries.length}`;
|
|
2038
|
+
}
|
|
2039
|
+
async function getDashboardData(storage, filter) {
|
|
2040
|
+
const allEntries = await Promise.resolve(storage.getAll());
|
|
2041
|
+
const sinceMs = parseSince(filter);
|
|
2042
|
+
const entries = sinceMs !== void 0 ? allEntries.filter((e) => new Date(e.timestamp).getTime() >= sinceMs) : allEntries;
|
|
2043
|
+
const byModel = {};
|
|
2044
|
+
const bySession = {};
|
|
2045
|
+
const byUser = {};
|
|
2046
|
+
const byFeature = {};
|
|
2047
|
+
let totalInput = 0;
|
|
2048
|
+
let totalOutput = 0;
|
|
2049
|
+
let totalCost = 0;
|
|
2050
|
+
for (const e of entries) {
|
|
2051
|
+
totalInput += e.inputTokens + (e.cachedTokens ?? 0) + (e.cacheCreationTokens ?? 0);
|
|
2052
|
+
totalOutput += e.outputTokens;
|
|
2053
|
+
totalCost += e.costUSD;
|
|
2054
|
+
const m = byModel[e.model] ??= {
|
|
2055
|
+
costUSD: 0,
|
|
2056
|
+
calls: 0,
|
|
2057
|
+
tokens: { input: 0, output: 0, reasoning: 0, cached: 0 }
|
|
2058
|
+
};
|
|
2059
|
+
m.costUSD += e.costUSD;
|
|
2060
|
+
m.calls += 1;
|
|
2061
|
+
m.tokens.input += e.inputTokens + (e.cachedTokens ?? 0) + (e.cacheCreationTokens ?? 0);
|
|
2062
|
+
m.tokens.output += e.outputTokens;
|
|
2063
|
+
m.tokens.reasoning += e.reasoningTokens ?? 0;
|
|
2064
|
+
m.tokens.cached += e.cachedTokens ?? 0;
|
|
2065
|
+
if (e.sessionId) {
|
|
2066
|
+
const s = bySession[e.sessionId] ??= { costUSD: 0, calls: 0 };
|
|
2067
|
+
s.costUSD += e.costUSD;
|
|
2068
|
+
s.calls += 1;
|
|
2069
|
+
}
|
|
2070
|
+
if (e.userId) {
|
|
2071
|
+
const u = byUser[e.userId] ??= { costUSD: 0, calls: 0 };
|
|
2072
|
+
u.costUSD += e.costUSD;
|
|
2073
|
+
u.calls += 1;
|
|
2074
|
+
}
|
|
2075
|
+
if (e.feature) {
|
|
2076
|
+
const f = byFeature[e.feature] ??= { costUSD: 0, calls: 0 };
|
|
2077
|
+
f.costUSD += e.costUSD;
|
|
2078
|
+
f.calls += 1;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2082
|
+
const periodFrom = entries[0]?.timestamp ?? now;
|
|
2083
|
+
const periodTo = entries[entries.length - 1]?.timestamp ?? now;
|
|
2084
|
+
const report = {
|
|
2085
|
+
totalCostUSD: totalCost,
|
|
2086
|
+
totalTokens: { input: totalInput, output: totalOutput },
|
|
2087
|
+
byModel,
|
|
2088
|
+
bySession,
|
|
2089
|
+
byUser,
|
|
2090
|
+
byFeature,
|
|
2091
|
+
period: { from: periodFrom, to: periodTo }
|
|
2092
|
+
};
|
|
2093
|
+
const forecastWindowMs = 24 * 60 * 60 * 1e3;
|
|
2094
|
+
const windowStart = Date.now() - forecastWindowMs;
|
|
2095
|
+
const windowEntries = allEntries.filter(
|
|
2096
|
+
(e) => new Date(e.timestamp).getTime() >= windowStart
|
|
2097
|
+
);
|
|
2098
|
+
let forecast;
|
|
2099
|
+
if (windowEntries.length < 2) {
|
|
2100
|
+
forecast = {
|
|
2101
|
+
burnRatePerHour: 0,
|
|
2102
|
+
projectedDailyCostUSD: 0,
|
|
2103
|
+
projectedMonthlyCostUSD: 0,
|
|
2104
|
+
basedOnHours: 0,
|
|
2105
|
+
basedOnPeriod: null
|
|
2106
|
+
};
|
|
2107
|
+
} else {
|
|
2108
|
+
const first = windowEntries[0]?.timestamp ?? "";
|
|
2109
|
+
const last = windowEntries[windowEntries.length - 1]?.timestamp ?? "";
|
|
2110
|
+
const actualMs = new Date(last).getTime() - new Date(first).getTime();
|
|
2111
|
+
const actualHours = actualMs / (1e3 * 60 * 60);
|
|
2112
|
+
if (actualHours < 1e-3) {
|
|
2113
|
+
forecast = {
|
|
2114
|
+
burnRatePerHour: 0,
|
|
2115
|
+
projectedDailyCostUSD: 0,
|
|
2116
|
+
projectedMonthlyCostUSD: 0,
|
|
2117
|
+
basedOnHours: 0,
|
|
2118
|
+
basedOnPeriod: { from: first, to: last }
|
|
2119
|
+
};
|
|
2120
|
+
} else {
|
|
2121
|
+
const windowCost = windowEntries.reduce((s, e) => s + e.costUSD, 0);
|
|
2122
|
+
const burnRatePerHour = windowCost / actualHours;
|
|
2123
|
+
forecast = {
|
|
2124
|
+
burnRatePerHour,
|
|
2125
|
+
projectedDailyCostUSD: burnRatePerHour * 24,
|
|
2126
|
+
projectedMonthlyCostUSD: burnRatePerHour * 24 * 30,
|
|
2127
|
+
basedOnHours: Math.round(actualHours * 100) / 100,
|
|
2128
|
+
basedOnPeriod: { from: first, to: last }
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
const timeSeries = buildTimeSeries(allEntries, sinceMs);
|
|
2133
|
+
return { report, forecast, timeSeries, lastUpdated: now };
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/dashboard/html.ts
|
|
2137
|
+
function getHtml(port) {
|
|
2138
|
+
void port;
|
|
2139
|
+
return `<!DOCTYPE html>
|
|
2140
|
+
<html lang="en">
|
|
2141
|
+
<head>
|
|
2142
|
+
<meta charset="UTF-8" />
|
|
2143
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2144
|
+
<title>tokenwatch dashboard</title>
|
|
2145
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
2146
|
+
<style>
|
|
2147
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2148
|
+
|
|
2149
|
+
:root {
|
|
2150
|
+
--bg: #0d1117;
|
|
2151
|
+
--surface: #161b22;
|
|
2152
|
+
--border: #30363d;
|
|
2153
|
+
--text: #e6edf3;
|
|
2154
|
+
--muted: #8b949e;
|
|
2155
|
+
--accent: #58a6ff;
|
|
2156
|
+
--green: #3fb950;
|
|
2157
|
+
--yellow: #d29922;
|
|
2158
|
+
--red: #f85149;
|
|
2159
|
+
--r: 6px;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
body {
|
|
2163
|
+
background: var(--bg);
|
|
2164
|
+
color: var(--text);
|
|
2165
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2166
|
+
font-size: 14px;
|
|
2167
|
+
line-height: 1.5;
|
|
2168
|
+
min-height: 100vh;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
|
2172
|
+
|
|
2173
|
+
/* \u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2174
|
+
header {
|
|
2175
|
+
display: flex;
|
|
2176
|
+
align-items: center;
|
|
2177
|
+
justify-content: space-between;
|
|
2178
|
+
margin-bottom: 24px;
|
|
2179
|
+
}
|
|
2180
|
+
header h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
|
|
2181
|
+
header h1 span { color: var(--accent); }
|
|
2182
|
+
|
|
2183
|
+
.live-badge {
|
|
2184
|
+
display: flex;
|
|
2185
|
+
align-items: center;
|
|
2186
|
+
gap: 6px;
|
|
2187
|
+
font-size: 12px;
|
|
2188
|
+
color: var(--muted);
|
|
2189
|
+
}
|
|
2190
|
+
.live-dot {
|
|
2191
|
+
width: 8px; height: 8px;
|
|
2192
|
+
border-radius: 50%;
|
|
2193
|
+
background: var(--green);
|
|
2194
|
+
animation: pulse 2s ease-in-out infinite;
|
|
2195
|
+
}
|
|
2196
|
+
@keyframes pulse {
|
|
2197
|
+
0%, 100% { opacity: 1; }
|
|
2198
|
+
50% { opacity: 0.3; }
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/* \u2500\u2500 Tabs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2202
|
+
.tabs {
|
|
2203
|
+
display: flex;
|
|
2204
|
+
gap: 4px;
|
|
2205
|
+
margin-bottom: 24px;
|
|
2206
|
+
background: var(--surface);
|
|
2207
|
+
border: 1px solid var(--border);
|
|
2208
|
+
border-radius: var(--r);
|
|
2209
|
+
padding: 4px;
|
|
2210
|
+
width: fit-content;
|
|
2211
|
+
}
|
|
2212
|
+
.tab {
|
|
2213
|
+
padding: 6px 14px;
|
|
2214
|
+
border-radius: calc(var(--r) - 2px);
|
|
2215
|
+
border: none;
|
|
2216
|
+
background: transparent;
|
|
2217
|
+
color: var(--muted);
|
|
2218
|
+
font-size: 13px;
|
|
2219
|
+
font-weight: 500;
|
|
2220
|
+
cursor: pointer;
|
|
2221
|
+
transition: background 0.15s, color 0.15s;
|
|
2222
|
+
}
|
|
2223
|
+
.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
|
2224
|
+
.tab.active { background: var(--accent); color: #fff; }
|
|
2225
|
+
|
|
2226
|
+
/* \u2500\u2500 Overview cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2227
|
+
.cards {
|
|
2228
|
+
display: grid;
|
|
2229
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2230
|
+
gap: 12px;
|
|
2231
|
+
margin-bottom: 24px;
|
|
2232
|
+
}
|
|
2233
|
+
.card {
|
|
2234
|
+
background: var(--surface);
|
|
2235
|
+
border: 1px solid var(--border);
|
|
2236
|
+
border-radius: var(--r);
|
|
2237
|
+
padding: 16px;
|
|
2238
|
+
}
|
|
2239
|
+
.card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 6px; }
|
|
2240
|
+
.card-value { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
2241
|
+
.card-sub { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
|
2242
|
+
|
|
2243
|
+
/* \u2500\u2500 Chart section \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2244
|
+
.charts {
|
|
2245
|
+
display: grid;
|
|
2246
|
+
grid-template-columns: 2fr 1fr;
|
|
2247
|
+
gap: 16px;
|
|
2248
|
+
margin-bottom: 24px;
|
|
2249
|
+
}
|
|
2250
|
+
@media (max-width: 768px) { .charts { grid-template-columns: 1fr; } }
|
|
2251
|
+
|
|
2252
|
+
.panel {
|
|
2253
|
+
background: var(--surface);
|
|
2254
|
+
border: 1px solid var(--border);
|
|
2255
|
+
border-radius: var(--r);
|
|
2256
|
+
padding: 16px;
|
|
2257
|
+
}
|
|
2258
|
+
.panel-title {
|
|
2259
|
+
font-size: 12px;
|
|
2260
|
+
font-weight: 600;
|
|
2261
|
+
text-transform: uppercase;
|
|
2262
|
+
letter-spacing: 0.5px;
|
|
2263
|
+
color: var(--muted);
|
|
2264
|
+
margin-bottom: 14px;
|
|
2265
|
+
}
|
|
2266
|
+
.chart-wrap { position: relative; height: 220px; }
|
|
2267
|
+
|
|
2268
|
+
/* \u2500\u2500 Breakdown tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2269
|
+
.section { margin-bottom: 24px; }
|
|
2270
|
+
.section-title {
|
|
2271
|
+
font-size: 12px;
|
|
2272
|
+
font-weight: 600;
|
|
2273
|
+
text-transform: uppercase;
|
|
2274
|
+
letter-spacing: 0.5px;
|
|
2275
|
+
color: var(--muted);
|
|
2276
|
+
margin-bottom: 10px;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
table {
|
|
2280
|
+
width: 100%;
|
|
2281
|
+
border-collapse: collapse;
|
|
2282
|
+
background: var(--surface);
|
|
2283
|
+
border: 1px solid var(--border);
|
|
2284
|
+
border-radius: var(--r);
|
|
2285
|
+
overflow: hidden;
|
|
2286
|
+
font-size: 13px;
|
|
2287
|
+
}
|
|
2288
|
+
thead { background: rgba(255,255,255,0.03); }
|
|
2289
|
+
th {
|
|
2290
|
+
padding: 10px 14px;
|
|
2291
|
+
text-align: left;
|
|
2292
|
+
font-weight: 600;
|
|
2293
|
+
color: var(--muted);
|
|
2294
|
+
font-size: 11px;
|
|
2295
|
+
text-transform: uppercase;
|
|
2296
|
+
letter-spacing: 0.4px;
|
|
2297
|
+
border-bottom: 1px solid var(--border);
|
|
2298
|
+
}
|
|
2299
|
+
th.num, td.num { text-align: right; }
|
|
2300
|
+
td {
|
|
2301
|
+
padding: 10px 14px;
|
|
2302
|
+
border-bottom: 1px solid rgba(48,54,61,0.5);
|
|
2303
|
+
font-variant-numeric: tabular-nums;
|
|
2304
|
+
color: var(--text);
|
|
2305
|
+
}
|
|
2306
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
2307
|
+
tbody tr:hover td { background: rgba(255,255,255,0.02); }
|
|
2308
|
+
|
|
2309
|
+
.bar-wrap { width: 80px; height: 6px; background: var(--border); border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 8px; }
|
|
2310
|
+
.bar-fill { height: 100%; border-radius: 3px; background: var(--accent); }
|
|
2311
|
+
|
|
2312
|
+
/* \u2500\u2500 Forecast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2313
|
+
.forecast-grid {
|
|
2314
|
+
display: grid;
|
|
2315
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
2316
|
+
gap: 12px;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/* \u2500\u2500 Empty 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 */
|
|
2320
|
+
.empty {
|
|
2321
|
+
color: var(--muted);
|
|
2322
|
+
font-size: 13px;
|
|
2323
|
+
padding: 20px 0;
|
|
2324
|
+
text-align: center;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/* \u2500\u2500 Collapsible \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2328
|
+
details summary {
|
|
2329
|
+
cursor: pointer;
|
|
2330
|
+
list-style: none;
|
|
2331
|
+
display: flex;
|
|
2332
|
+
align-items: center;
|
|
2333
|
+
gap: 6px;
|
|
2334
|
+
font-size: 12px;
|
|
2335
|
+
font-weight: 600;
|
|
2336
|
+
text-transform: uppercase;
|
|
2337
|
+
letter-spacing: 0.5px;
|
|
2338
|
+
color: var(--muted);
|
|
2339
|
+
margin-bottom: 10px;
|
|
2340
|
+
user-select: none;
|
|
2341
|
+
}
|
|
2342
|
+
details summary::before { content: '\u25B6'; font-size: 10px; transition: transform 0.15s; }
|
|
2343
|
+
details[open] summary::before { transform: rotate(90deg); }
|
|
2344
|
+
|
|
2345
|
+
/* \u2500\u2500 Last updated \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2346
|
+
.footer {
|
|
2347
|
+
font-size: 11px;
|
|
2348
|
+
color: var(--muted);
|
|
2349
|
+
text-align: center;
|
|
2350
|
+
padding: 16px 0 0;
|
|
2351
|
+
}
|
|
2352
|
+
</style>
|
|
2353
|
+
</head>
|
|
2354
|
+
<body>
|
|
2355
|
+
<div class="container">
|
|
2356
|
+
|
|
2357
|
+
<header>
|
|
2358
|
+
<h1>token<span>watch</span></h1>
|
|
2359
|
+
<div class="live-badge">
|
|
2360
|
+
<div class="live-dot"></div>
|
|
2361
|
+
<span id="live-label">live</span>
|
|
2362
|
+
</div>
|
|
2363
|
+
</header>
|
|
2364
|
+
|
|
2365
|
+
<div class="tabs">
|
|
2366
|
+
<button class="tab" data-filter="1h">1h</button>
|
|
2367
|
+
<button class="tab active" data-filter="24h">24h</button>
|
|
2368
|
+
<button class="tab" data-filter="7d">7d</button>
|
|
2369
|
+
<button class="tab" data-filter="30d">30d</button>
|
|
2370
|
+
<button class="tab" data-filter="all">All</button>
|
|
2371
|
+
</div>
|
|
2372
|
+
|
|
2373
|
+
<div class="cards">
|
|
2374
|
+
<div class="card">
|
|
2375
|
+
<div class="card-label">Total Cost</div>
|
|
2376
|
+
<div class="card-value" id="card-cost">\u2014</div>
|
|
2377
|
+
<div class="card-sub" id="card-period"></div>
|
|
2378
|
+
</div>
|
|
2379
|
+
<div class="card">
|
|
2380
|
+
<div class="card-label">Input Tokens</div>
|
|
2381
|
+
<div class="card-value" id="card-input">\u2014</div>
|
|
2382
|
+
</div>
|
|
2383
|
+
<div class="card">
|
|
2384
|
+
<div class="card-label">Output Tokens</div>
|
|
2385
|
+
<div class="card-value" id="card-output">\u2014</div>
|
|
2386
|
+
</div>
|
|
2387
|
+
<div class="card">
|
|
2388
|
+
<div class="card-label">Total Calls</div>
|
|
2389
|
+
<div class="card-value" id="card-calls">\u2014</div>
|
|
2390
|
+
</div>
|
|
2391
|
+
<div class="card">
|
|
2392
|
+
<div class="card-label">Burn Rate</div>
|
|
2393
|
+
<div class="card-value" id="card-burn">\u2014</div>
|
|
2394
|
+
<div class="card-sub">per hour</div>
|
|
2395
|
+
</div>
|
|
2396
|
+
</div>
|
|
2397
|
+
|
|
2398
|
+
<div class="charts">
|
|
2399
|
+
<div class="panel">
|
|
2400
|
+
<div class="panel-title">Cost over time</div>
|
|
2401
|
+
<div class="chart-wrap">
|
|
2402
|
+
<canvas id="chart-line"></canvas>
|
|
2403
|
+
</div>
|
|
2404
|
+
</div>
|
|
2405
|
+
<div class="panel">
|
|
2406
|
+
<div class="panel-title">By model</div>
|
|
2407
|
+
<div class="chart-wrap">
|
|
2408
|
+
<canvas id="chart-doughnut"></canvas>
|
|
2409
|
+
</div>
|
|
2410
|
+
</div>
|
|
2411
|
+
</div>
|
|
2412
|
+
|
|
2413
|
+
<div class="section">
|
|
2414
|
+
<div class="section-title">Model breakdown</div>
|
|
2415
|
+
<div id="model-table-wrap"></div>
|
|
2416
|
+
</div>
|
|
2417
|
+
|
|
2418
|
+
<details id="users-section" style="margin-bottom:24px;">
|
|
2419
|
+
<summary>By user</summary>
|
|
2420
|
+
<div id="user-table-wrap"></div>
|
|
2421
|
+
</details>
|
|
2422
|
+
|
|
2423
|
+
<details id="features-section" style="margin-bottom:24px;">
|
|
2424
|
+
<summary>By feature</summary>
|
|
2425
|
+
<div id="feature-table-wrap"></div>
|
|
2426
|
+
</details>
|
|
2427
|
+
|
|
2428
|
+
<div class="section">
|
|
2429
|
+
<div class="section-title">Cost forecast</div>
|
|
2430
|
+
<div class="forecast-grid">
|
|
2431
|
+
<div class="card">
|
|
2432
|
+
<div class="card-label">Projected daily</div>
|
|
2433
|
+
<div class="card-value" id="fc-daily">\u2014</div>
|
|
2434
|
+
<div class="card-sub" id="fc-window"></div>
|
|
2435
|
+
</div>
|
|
2436
|
+
<div class="card">
|
|
2437
|
+
<div class="card-label">Projected monthly</div>
|
|
2438
|
+
<div class="card-value" id="fc-monthly">\u2014</div>
|
|
2439
|
+
</div>
|
|
2440
|
+
<div class="card">
|
|
2441
|
+
<div class="card-label">Burn rate / hr</div>
|
|
2442
|
+
<div class="card-value" id="fc-burn">\u2014</div>
|
|
2443
|
+
</div>
|
|
2444
|
+
</div>
|
|
2445
|
+
</div>
|
|
2446
|
+
|
|
2447
|
+
<div class="footer" id="footer-updated"></div>
|
|
2448
|
+
|
|
2449
|
+
</div><!-- /container -->
|
|
2450
|
+
|
|
2451
|
+
<script>
|
|
2452
|
+
(function () {
|
|
2453
|
+
'use strict';
|
|
2454
|
+
|
|
2455
|
+
// \u2500\u2500 Palette \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2456
|
+
const PALETTE = [
|
|
2457
|
+
'#58a6ff','#3fb950','#f78166','#d29922','#bc8cff',
|
|
2458
|
+
'#79c0ff','#56d364','#ffa657','#ff7b72','#a5d6ff',
|
|
2459
|
+
];
|
|
2460
|
+
|
|
2461
|
+
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2462
|
+
let evtSource = null;
|
|
2463
|
+
let activeFilter = '24h';
|
|
2464
|
+
let lineChart = null;
|
|
2465
|
+
let doughnutChart = null;
|
|
2466
|
+
|
|
2467
|
+
// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2468
|
+
function escHtml(str) {
|
|
2469
|
+
return String(str)
|
|
2470
|
+
.replace(/&/g, '&')
|
|
2471
|
+
.replace(/</g, '<')
|
|
2472
|
+
.replace(/>/g, '>')
|
|
2473
|
+
.replace(/"/g, '"');
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function fmtUSD(n) {
|
|
2477
|
+
if (n === 0) return '$0.00';
|
|
2478
|
+
if (n < 0.001) return '$' + n.toFixed(6);
|
|
2479
|
+
if (n < 1) return '$' + n.toFixed(4);
|
|
2480
|
+
return '$' + n.toFixed(2);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function fmtNum(n) {
|
|
2484
|
+
return n.toLocaleString('en-US');
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function fmtDate(iso) {
|
|
2488
|
+
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function totalCalls(byModel) {
|
|
2492
|
+
return Object.values(byModel).reduce(function(s, m) { return s + m.calls; }, 0);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// \u2500\u2500 Tab setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2496
|
+
document.querySelectorAll('.tab').forEach(function(btn) {
|
|
2497
|
+
btn.addEventListener('click', function() {
|
|
2498
|
+
document.querySelectorAll('.tab').forEach(function(b) { b.classList.remove('active'); });
|
|
2499
|
+
btn.classList.add('active');
|
|
2500
|
+
activeFilter = btn.dataset.filter;
|
|
2501
|
+
reconnect();
|
|
2502
|
+
});
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
// \u2500\u2500 SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2506
|
+
function reconnect() {
|
|
2507
|
+
if (evtSource) { evtSource.close(); evtSource = null; }
|
|
2508
|
+
evtSource = new EventSource('/events?filter=' + encodeURIComponent(activeFilter));
|
|
2509
|
+
evtSource.onmessage = function(e) {
|
|
2510
|
+
try { updateUI(JSON.parse(e.data)); } catch (_) {}
|
|
2511
|
+
};
|
|
2512
|
+
evtSource.onerror = function() {
|
|
2513
|
+
document.getElementById('live-label').textContent = 'reconnecting\u2026';
|
|
2514
|
+
};
|
|
2515
|
+
evtSource.onopen = function() {
|
|
2516
|
+
document.getElementById('live-label').textContent = 'live';
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// \u2500\u2500 UI update \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2521
|
+
function updateUI(data) {
|
|
2522
|
+
const r = data.report;
|
|
2523
|
+
const fc = data.forecast;
|
|
2524
|
+
const ts = data.timeSeries;
|
|
2525
|
+
|
|
2526
|
+
// Cards
|
|
2527
|
+
document.getElementById('card-cost').textContent = fmtUSD(r.totalCostUSD);
|
|
2528
|
+
document.getElementById('card-input').textContent = fmtNum(r.totalTokens.input);
|
|
2529
|
+
document.getElementById('card-output').textContent = fmtNum(r.totalTokens.output);
|
|
2530
|
+
document.getElementById('card-calls').textContent = fmtNum(totalCalls(r.byModel));
|
|
2531
|
+
document.getElementById('card-burn').textContent = fmtUSD(fc.burnRatePerHour);
|
|
2532
|
+
document.getElementById('card-period').textContent =
|
|
2533
|
+
r.period.from !== r.period.to
|
|
2534
|
+
? fmtDate(r.period.from) + ' \u2013 ' + fmtDate(r.period.to)
|
|
2535
|
+
: '';
|
|
2536
|
+
|
|
2537
|
+
// Line chart
|
|
2538
|
+
const labels = ts.map(function(b) {
|
|
2539
|
+
try { return new Date(b.bucket).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
|
2540
|
+
catch { return b.bucket; }
|
|
2541
|
+
});
|
|
2542
|
+
const costs = ts.map(function(b) { return b.cost; });
|
|
2543
|
+
|
|
2544
|
+
if (!lineChart) {
|
|
2545
|
+
const ctx = document.getElementById('chart-line').getContext('2d');
|
|
2546
|
+
lineChart = new Chart(ctx, {
|
|
2547
|
+
type: 'line',
|
|
2548
|
+
data: {
|
|
2549
|
+
labels: labels,
|
|
2550
|
+
datasets: [{
|
|
2551
|
+
label: 'Cost (USD)',
|
|
2552
|
+
data: costs,
|
|
2553
|
+
borderColor: '#58a6ff',
|
|
2554
|
+
backgroundColor: 'rgba(88,166,255,0.08)',
|
|
2555
|
+
borderWidth: 2,
|
|
2556
|
+
pointRadius: 3,
|
|
2557
|
+
pointBackgroundColor: '#58a6ff',
|
|
2558
|
+
fill: true,
|
|
2559
|
+
tension: 0.3,
|
|
2560
|
+
}],
|
|
2561
|
+
},
|
|
2562
|
+
options: {
|
|
2563
|
+
responsive: true, maintainAspectRatio: false,
|
|
2564
|
+
plugins: { legend: { display: false } },
|
|
2565
|
+
scales: {
|
|
2566
|
+
x: { ticks: { color: '#8b949e', maxTicksLimit: 8 }, grid: { color: '#21262d' } },
|
|
2567
|
+
y: {
|
|
2568
|
+
ticks: {
|
|
2569
|
+
color: '#8b949e',
|
|
2570
|
+
callback: function(v) { return '$' + Number(v).toFixed(4); },
|
|
2571
|
+
},
|
|
2572
|
+
grid: { color: '#21262d' },
|
|
2573
|
+
},
|
|
2574
|
+
},
|
|
2575
|
+
},
|
|
2576
|
+
});
|
|
2577
|
+
} else {
|
|
2578
|
+
lineChart.data.labels = labels;
|
|
2579
|
+
lineChart.data.datasets[0].data = costs;
|
|
2580
|
+
lineChart.update('none');
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Doughnut chart
|
|
2584
|
+
const modelEntries = Object.entries(r.byModel);
|
|
2585
|
+
const dLabels = modelEntries.map(function(x) { return x[0]; });
|
|
2586
|
+
const dData = modelEntries.map(function(x) { return x[1].costUSD; });
|
|
2587
|
+
const dColors = dLabels.map(function(_, i) { return PALETTE[i % PALETTE.length]; });
|
|
2588
|
+
|
|
2589
|
+
if (!doughnutChart) {
|
|
2590
|
+
const ctx2 = document.getElementById('chart-doughnut').getContext('2d');
|
|
2591
|
+
doughnutChart = new Chart(ctx2, {
|
|
2592
|
+
type: 'doughnut',
|
|
2593
|
+
data: { labels: dLabels, datasets: [{ data: dData, backgroundColor: dColors, borderWidth: 0, hoverOffset: 4 }] },
|
|
2594
|
+
options: {
|
|
2595
|
+
responsive: true, maintainAspectRatio: false,
|
|
2596
|
+
cutout: '65%',
|
|
2597
|
+
plugins: {
|
|
2598
|
+
legend: {
|
|
2599
|
+
position: 'bottom',
|
|
2600
|
+
labels: { color: '#8b949e', boxWidth: 10, padding: 12, font: { size: 11 } },
|
|
2601
|
+
},
|
|
2602
|
+
},
|
|
2603
|
+
},
|
|
2604
|
+
});
|
|
2605
|
+
} else {
|
|
2606
|
+
doughnutChart.data.labels = dLabels;
|
|
2607
|
+
doughnutChart.data.datasets[0].data = dData;
|
|
2608
|
+
doughnutChart.data.datasets[0].backgroundColor = dColors;
|
|
2609
|
+
doughnutChart.update('none');
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Model table
|
|
2613
|
+
const modelWrap = document.getElementById('model-table-wrap');
|
|
2614
|
+
if (modelEntries.length === 0) {
|
|
2615
|
+
modelWrap.innerHTML = '<p class="empty">No data for this period.</p>';
|
|
2616
|
+
} else {
|
|
2617
|
+
const totalCost = r.totalCostUSD || 1;
|
|
2618
|
+
let html = '<table><thead><tr>' +
|
|
2619
|
+
'<th>Model</th>' +
|
|
2620
|
+
'<th class="num">Calls</th>' +
|
|
2621
|
+
'<th class="num">In tokens</th>' +
|
|
2622
|
+
'<th class="num">Out tokens</th>' +
|
|
2623
|
+
'<th class="num">Cost</th>' +
|
|
2624
|
+
'<th class="num">Share</th>' +
|
|
2625
|
+
'</tr></thead><tbody>';
|
|
2626
|
+
const sorted = modelEntries.slice().sort(function(a, b) { return b[1].costUSD - a[1].costUSD; });
|
|
2627
|
+
sorted.forEach(function(entry) {
|
|
2628
|
+
const name = entry[0]; const m = entry[1];
|
|
2629
|
+
const pct = (m.costUSD / totalCost * 100).toFixed(1);
|
|
2630
|
+
const barW = Math.round(m.costUSD / totalCost * 80);
|
|
2631
|
+
html += '<tr>' +
|
|
2632
|
+
'<td>' + escHtml(name) + '</td>' +
|
|
2633
|
+
'<td class="num">' + fmtNum(m.calls) + '</td>' +
|
|
2634
|
+
'<td class="num">' + fmtNum(m.tokens.input) + '</td>' +
|
|
2635
|
+
'<td class="num">' + fmtNum(m.tokens.output) + '</td>' +
|
|
2636
|
+
'<td class="num">' + fmtUSD(m.costUSD) + '</td>' +
|
|
2637
|
+
'<td class="num">' + pct + '%' +
|
|
2638
|
+
'<span class="bar-wrap"><span class="bar-fill" style="width:' + barW + 'px"></span></span>' +
|
|
2639
|
+
'</td></tr>';
|
|
2640
|
+
});
|
|
2641
|
+
html += '</tbody></table>';
|
|
2642
|
+
modelWrap.innerHTML = html;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// User table
|
|
2646
|
+
const userEntries = Object.entries(r.byUser);
|
|
2647
|
+
const usersSection = document.getElementById('users-section');
|
|
2648
|
+
usersSection.style.display = userEntries.length === 0 ? 'none' : '';
|
|
2649
|
+
if (userEntries.length > 0) {
|
|
2650
|
+
let html = '<table><thead><tr><th>User</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2651
|
+
userEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2652
|
+
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2653
|
+
});
|
|
2654
|
+
html += '</tbody></table>';
|
|
2655
|
+
document.getElementById('user-table-wrap').innerHTML = html;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// Feature table
|
|
2659
|
+
const featureEntries = Object.entries(r.byFeature);
|
|
2660
|
+
const featuresSection = document.getElementById('features-section');
|
|
2661
|
+
featuresSection.style.display = featureEntries.length === 0 ? 'none' : '';
|
|
2662
|
+
if (featureEntries.length > 0) {
|
|
2663
|
+
let html = '<table><thead><tr><th>Feature</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
|
|
2664
|
+
featureEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
|
|
2665
|
+
html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
|
|
2666
|
+
});
|
|
2667
|
+
html += '</tbody></table>';
|
|
2668
|
+
document.getElementById('feature-table-wrap').innerHTML = html;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// Forecast
|
|
2672
|
+
document.getElementById('fc-daily').textContent = fmtUSD(fc.projectedDailyCostUSD);
|
|
2673
|
+
document.getElementById('fc-monthly').textContent = fmtUSD(fc.projectedMonthlyCostUSD);
|
|
2674
|
+
document.getElementById('fc-burn').textContent = fmtUSD(fc.burnRatePerHour);
|
|
2675
|
+
document.getElementById('fc-window').textContent =
|
|
2676
|
+
fc.basedOnHours > 0 ? 'based on ' + fc.basedOnHours.toFixed(1) + 'h of data' : 'insufficient data';
|
|
2677
|
+
|
|
2678
|
+
// Footer
|
|
2679
|
+
document.getElementById('footer-updated').textContent =
|
|
2680
|
+
'Last updated: ' + fmtDate(data.lastUpdated);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// \u2500\u2500 Boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2684
|
+
reconnect();
|
|
2685
|
+
})();
|
|
2686
|
+
</script>
|
|
2687
|
+
</body>
|
|
2688
|
+
</html>`;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// src/dashboard/server.ts
|
|
2692
|
+
function startDashboardServer(storage, port) {
|
|
2693
|
+
const server = createServer((req, res) => {
|
|
2694
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
2695
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
2696
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2697
|
+
res.end(getHtml(port));
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
2701
|
+
const filter = url.searchParams.get("filter") ?? "24h";
|
|
2702
|
+
res.writeHead(200, {
|
|
2703
|
+
"Content-Type": "text/event-stream",
|
|
2704
|
+
"Cache-Control": "no-cache",
|
|
2705
|
+
"Connection": "keep-alive",
|
|
2706
|
+
"X-Accel-Buffering": "no"
|
|
2707
|
+
});
|
|
2708
|
+
res.flushHeaders();
|
|
2709
|
+
let lastFingerprint = "";
|
|
2710
|
+
async function sendData() {
|
|
2711
|
+
try {
|
|
2712
|
+
const data = await getDashboardData(storage, filter);
|
|
2713
|
+
const fp = getFingerprint(data);
|
|
2714
|
+
if (fp !== lastFingerprint) {
|
|
2715
|
+
lastFingerprint = fp;
|
|
2716
|
+
res.write(`data: ${JSON.stringify(data)}
|
|
2717
|
+
|
|
2718
|
+
`);
|
|
2719
|
+
}
|
|
2720
|
+
} catch {
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
void sendData();
|
|
2724
|
+
const timer = setInterval(() => {
|
|
2725
|
+
void sendData();
|
|
2726
|
+
}, 3e3);
|
|
2727
|
+
res.on("close", () => {
|
|
2728
|
+
clearInterval(timer);
|
|
2729
|
+
});
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
2733
|
+
res.end("Not found");
|
|
2734
|
+
});
|
|
2735
|
+
server.on("error", (err) => {
|
|
2736
|
+
if (err.code === "EADDRINUSE") {
|
|
2737
|
+
console.error(`[tokenwatch] Port ${port} is already in use. Try: tokenwatch dashboard --port <other>`);
|
|
2738
|
+
process.exit(1);
|
|
2739
|
+
}
|
|
2740
|
+
throw err;
|
|
2741
|
+
});
|
|
2742
|
+
server.listen(port, () => {
|
|
2743
|
+
console.log(`tokenwatch dashboard \u2192 http://localhost:${port}`);
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
|
|
1929
2747
|
// bin/cli.ts
|
|
1930
2748
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1931
2749
|
var DB_PATH2 = join3(homedir3(), ".tokenwatch", "usage.db");
|
|
@@ -2014,20 +2832,36 @@ async function cmdReport() {
|
|
|
2014
2832
|
}
|
|
2015
2833
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
|
|
2016
2834
|
}
|
|
2835
|
+
async function cmdDashboard(port) {
|
|
2836
|
+
if (!existsSync2(DB_PATH2)) {
|
|
2837
|
+
console.log(`No SQLite database found at ${DB_PATH2}`);
|
|
2838
|
+
console.log("Start your app with storage: 'sqlite' to begin recording usage.");
|
|
2839
|
+
process.exit(1);
|
|
2840
|
+
}
|
|
2841
|
+
let storage;
|
|
2842
|
+
try {
|
|
2843
|
+
storage = new SqliteStorage(DB_PATH2);
|
|
2844
|
+
} catch {
|
|
2845
|
+
console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
|
|
2846
|
+
console.error("Run: npm install better-sqlite3");
|
|
2847
|
+
process.exit(1);
|
|
2848
|
+
}
|
|
2849
|
+
startDashboardServer(storage, port);
|
|
2850
|
+
}
|
|
2017
2851
|
function cmdHelp() {
|
|
2018
2852
|
console.log(`
|
|
2019
2853
|
tokenwatch \u2014 CLI
|
|
2020
2854
|
|
|
2021
2855
|
Commands:
|
|
2022
|
-
sync
|
|
2023
|
-
prices
|
|
2024
|
-
report
|
|
2025
|
-
|
|
2856
|
+
sync Fetch and cache latest model prices from remote
|
|
2857
|
+
prices List all bundled models and their current prices
|
|
2858
|
+
report Show last saved usage report (requires SQLite storage)
|
|
2859
|
+
dashboard [--port N] Open local web dashboard (default port: 4242)
|
|
2860
|
+
help Show this help message
|
|
2026
2861
|
`.trim());
|
|
2027
2862
|
}
|
|
2028
2863
|
async function main() {
|
|
2029
2864
|
const [, , cmd, ...args] = process.argv;
|
|
2030
|
-
void args;
|
|
2031
2865
|
switch (cmd) {
|
|
2032
2866
|
case "sync":
|
|
2033
2867
|
await cmdSync();
|
|
@@ -2038,6 +2872,12 @@ async function main() {
|
|
|
2038
2872
|
case "report":
|
|
2039
2873
|
await cmdReport();
|
|
2040
2874
|
break;
|
|
2875
|
+
case "dashboard": {
|
|
2876
|
+
const portFlagIdx = args.indexOf("--port");
|
|
2877
|
+
const port = portFlagIdx !== -1 ? parseInt(args[portFlagIdx + 1] ?? "4242", 10) : 4242;
|
|
2878
|
+
await cmdDashboard(port);
|
|
2879
|
+
break;
|
|
2880
|
+
}
|
|
2041
2881
|
case "help":
|
|
2042
2882
|
case void 0:
|
|
2043
2883
|
cmdHelp();
|