claude-usage-dashboard 1.3.14 → 1.4.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/bin/cli.cjs +13 -1
- package/bin/cli.js +16 -2
- package/bin/cli.sh +11 -11
- package/package.json +43 -40
- package/public/css/style.css +265 -265
- package/public/index.html +108 -108
- package/public/js/api.js +16 -16
- package/public/js/app.js +304 -304
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +151 -151
- package/server/credentials.js +112 -112
- package/server/index.js +45 -33
- package/server/parser.js +129 -109
- package/server/pricing.js +52 -52
- package/server/routes/api.js +130 -127
- package/server/sync.js +69 -0
package/server/routes/api.js
CHANGED
|
@@ -1,127 +1,130 @@
|
|
|
1
|
-
import { Router } from 'express';
|
|
2
|
-
import { parseLogDirectory } from '../parser.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
total.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
res.
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
router.get('/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
apiCost =
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
res.json(
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { parseLogDirectory, parseMultiMachineDirectory } from '../parser.js';
|
|
3
|
+
import { syncLocalToShared } from '../sync.js';
|
|
4
|
+
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
5
|
+
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
6
|
+
import { createQuotaFetcher } from '../quota.js';
|
|
7
|
+
import { getSubscriptionInfo } from '../credentials.js';
|
|
8
|
+
|
|
9
|
+
export function createApiRouter(logBaseDir, options = {}) {
|
|
10
|
+
const router = Router();
|
|
11
|
+
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
12
|
+
let cachedRecords = [];
|
|
13
|
+
let lastRefreshed = null;
|
|
14
|
+
|
|
15
|
+
async function refreshRecords() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
18
|
+
try {
|
|
19
|
+
if (options.syncDir) {
|
|
20
|
+
await syncLocalToShared(logBaseDir, options.syncDir, options.machineName);
|
|
21
|
+
cachedRecords = parseMultiMachineDirectory(options.syncDir);
|
|
22
|
+
} else {
|
|
23
|
+
cachedRecords = parseLogDirectory(logBaseDir);
|
|
24
|
+
}
|
|
25
|
+
lastRefreshed = now;
|
|
26
|
+
console.log(`Parsed ${cachedRecords.length} records${options.syncDir ? ' (sync mode)' : ''}`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('Failed to parse log directory:', err.message);
|
|
29
|
+
if (!lastRefreshed) lastRefreshed = now;
|
|
30
|
+
}
|
|
31
|
+
return cachedRecords;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function applyFilters(query) {
|
|
35
|
+
let records = filterByDateRange(await refreshRecords(), query.from, query.to);
|
|
36
|
+
if (query.project) records = records.filter(r => r.project === query.project);
|
|
37
|
+
if (query.model) records = records.filter(r => r.model === query.model);
|
|
38
|
+
return records;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
router.get('/usage', async (req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
const records = await applyFilters(req.query);
|
|
44
|
+
const granularity = req.query.granularity || autoGranularity(req.query.from, req.query.to);
|
|
45
|
+
const buckets = aggregateByTime(records, granularity);
|
|
46
|
+
const total = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_api_cost_usd: 0 };
|
|
47
|
+
for (const r of records) {
|
|
48
|
+
total.input_tokens += r.input_tokens; total.output_tokens += r.output_tokens;
|
|
49
|
+
total.cache_read_tokens += r.cache_read_tokens; total.cache_creation_tokens += r.cache_creation_tokens;
|
|
50
|
+
total.estimated_api_cost_usd += calculateRecordCost(r);
|
|
51
|
+
}
|
|
52
|
+
total.estimated_api_cost_usd = Math.round(total.estimated_api_cost_usd * 100) / 100;
|
|
53
|
+
res.json({ granularity, buckets, total });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
res.status(500).json({ error: err.message, code: 'PARSE_ERROR' });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.get('/models', async (req, res) => { res.json({ models: aggregateByModel(await applyFilters(req.query)) }); });
|
|
60
|
+
router.get('/projects', async (req, res) => { res.json({ projects: aggregateByProject(await applyFilters(req.query)) }); });
|
|
61
|
+
|
|
62
|
+
router.get('/sessions', async (req, res) => {
|
|
63
|
+
const records = await applyFilters(req.query);
|
|
64
|
+
let sessions = aggregateBySession(records);
|
|
65
|
+
const sort = req.query.sort || 'date';
|
|
66
|
+
const order = req.query.order || 'desc';
|
|
67
|
+
const sortFn = { date: (a, b) => new Date(b.startTime) - new Date(a.startTime), cost: (a, b) => b.estimated_cost_usd - a.estimated_cost_usd, tokens: (a, b) => b.total_tokens - a.total_tokens }[sort] || ((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
|
68
|
+
sessions.sort(sortFn);
|
|
69
|
+
if (order === 'asc') sessions.reverse();
|
|
70
|
+
const totalTokens = sessions.reduce((sum, s) => sum + s.total_tokens, 0);
|
|
71
|
+
const totalCost = sessions.reduce((sum, s) => sum + s.estimated_cost_usd, 0);
|
|
72
|
+
const page = parseInt(req.query.page) || 1;
|
|
73
|
+
const limit = parseInt(req.query.limit) || 20;
|
|
74
|
+
const totalSessions = sessions.length;
|
|
75
|
+
const totalPages = Math.ceil(totalSessions / limit);
|
|
76
|
+
sessions = sessions.slice((page - 1) * limit, page * limit);
|
|
77
|
+
res.json({ sessions, pagination: { page, limit, total_sessions: totalSessions, total_pages: totalPages }, totals: { total_tokens: totalTokens, estimated_cost_usd: Math.round(totalCost * 100) / 100 } });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
router.get('/cost', async (req, res) => {
|
|
81
|
+
const records = await applyFilters(req.query);
|
|
82
|
+
const plan = req.query.plan || 'max5x';
|
|
83
|
+
const customPrice = req.query.customPrice ? parseFloat(req.query.customPrice) : null;
|
|
84
|
+
const subscriptionCost = customPrice || PLAN_DEFAULTS[plan] || 100;
|
|
85
|
+
let apiCost = 0;
|
|
86
|
+
for (const r of records) apiCost += calculateRecordCost(r);
|
|
87
|
+
apiCost = Math.round(apiCost * 100) / 100;
|
|
88
|
+
const dayMap = new Map();
|
|
89
|
+
for (const r of records) {
|
|
90
|
+
const d = new Date(r.timestamp);
|
|
91
|
+
const day = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
92
|
+
dayMap.set(day, (dayMap.get(day) || 0) + calculateRecordCost(r));
|
|
93
|
+
}
|
|
94
|
+
const costPerDay = Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, cost]) => {
|
|
95
|
+
const d = new Date(date);
|
|
96
|
+
const daysInMonth = new Date(d.getUTCFullYear(), d.getUTCMonth() + 1, 0).getUTCDate();
|
|
97
|
+
return { date, api_cost: Math.round(cost * 100) / 100, subscription_daily: Math.round((subscriptionCost / daysInMonth) * 100) / 100 };
|
|
98
|
+
});
|
|
99
|
+
const savings = apiCost - subscriptionCost;
|
|
100
|
+
res.json({ plan, subscription_cost_usd: subscriptionCost, api_equivalent_cost_usd: apiCost, savings_usd: Math.round(savings * 100) / 100, savings_percent: apiCost > 0 ? Math.round((savings / apiCost) * 1000) / 10 : 0, cost_per_day: costPerDay });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
router.get('/cache', async (req, res) => { res.json(aggregateCache(await applyFilters(req.query))); });
|
|
104
|
+
|
|
105
|
+
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
106
|
+
router.get('/quota', async (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const data = await quotaFetcher.fetchQuota();
|
|
109
|
+
res.json(data);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
res.json({ available: false, error: err.message });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.get('/subscription', (req, res) => {
|
|
116
|
+
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
117
|
+
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
router.get('/status', async (req, res) => {
|
|
121
|
+
await refreshRecords();
|
|
122
|
+
res.json({
|
|
123
|
+
record_count: cachedRecords.length,
|
|
124
|
+
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
125
|
+
cache_ttl_ms: CACHE_TTL_MS,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return router;
|
|
130
|
+
}
|
package/server/sync.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const ILLEGAL_CHARS = /[/\\:*?"<>|]/g;
|
|
5
|
+
|
|
6
|
+
export function sanitizeMachineName(name) {
|
|
7
|
+
let clean = name.replace(ILLEGAL_CHARS, '-').trim().replace(/^\.+|\.+$/g, '').trim();
|
|
8
|
+
return clean || 'unknown-host';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function syncLocalToShared(localDir, syncDir, machineName) {
|
|
12
|
+
const safeName = sanitizeMachineName(machineName);
|
|
13
|
+
const machineDir = path.join(syncDir, safeName);
|
|
14
|
+
let syncedFiles = 0;
|
|
15
|
+
const startTime = Date.now();
|
|
16
|
+
|
|
17
|
+
let projectDirs;
|
|
18
|
+
try {
|
|
19
|
+
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
|
20
|
+
projectDirs = entries.filter(d => d.isDirectory());
|
|
21
|
+
} catch {
|
|
22
|
+
return { syncedFiles, machineName: safeName };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const dir of projectDirs) {
|
|
26
|
+
const localProjPath = path.join(localDir, dir.name);
|
|
27
|
+
let files;
|
|
28
|
+
try {
|
|
29
|
+
files = (await fs.readdir(localProjPath)).filter(f => f.endsWith('.jsonl'));
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const localFile = path.join(localProjPath, file);
|
|
36
|
+
const sharedFile = path.join(machineDir, dir.name, file);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const localStat = await fs.stat(localFile);
|
|
40
|
+
let needsSync = false;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const sharedStat = await fs.stat(sharedFile);
|
|
44
|
+
needsSync = localStat.size > sharedStat.size;
|
|
45
|
+
} catch {
|
|
46
|
+
needsSync = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (needsSync) {
|
|
50
|
+
await fs.mkdir(path.join(machineDir, dir.name), { recursive: true });
|
|
51
|
+
await fs.copyFile(localFile, sharedFile);
|
|
52
|
+
syncedFiles++;
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn(`Sync warning: failed to sync ${file}: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const elapsed = Date.now() - startTime;
|
|
61
|
+
if (syncedFiles > 0) {
|
|
62
|
+
console.log(`Synced ${syncedFiles} files to ${machineDir}`);
|
|
63
|
+
}
|
|
64
|
+
if (elapsed > 30000) {
|
|
65
|
+
console.warn(`Sync took ${Math.round(elapsed / 1000)}s — shared folder may be on a slow mount`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { syncedFiles, machineName: safeName };
|
|
69
|
+
}
|