claude-usage-dashboard 1.5.0 → 1.5.2
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 +122 -122
- package/bin/cli.cjs +20 -20
- package/bin/cli.js +16 -16
- package/bin/cli.sh +11 -11
- package/package.json +43 -43
- package/public/css/style.css +287 -287
- package/public/index.html +123 -123
- package/public/js/api.js +17 -17
- package/public/js/app.js +326 -326
- 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/quota-cycles.js +209 -209
- 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 +53 -45
- package/server/parser.js +129 -129
- package/server/pricing.js +52 -52
- package/server/quota-cycles.js +274 -274
- package/server/routes/api.js +175 -175
- package/server/sync.js +69 -69
package/server/routes/api.js
CHANGED
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
import { Router } from 'express';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import { parseLogDirectory, parseMultiMachineDirectory } from '../parser.js';
|
|
4
|
-
import { syncLocalToShared } from '../sync.js';
|
|
5
|
-
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
6
|
-
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
7
|
-
import { createQuotaFetcher } from '../quota.js';
|
|
8
|
-
import { getSubscriptionInfo } from '../credentials.js';
|
|
9
|
-
import { updateQuotaCycleSnapshot, loadQuotaCycles } from '../quota-cycles.js';
|
|
10
|
-
|
|
11
|
-
export function createApiRouter(logBaseDir, options = {}) {
|
|
12
|
-
const router = Router();
|
|
13
|
-
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
14
|
-
let cachedRecords = [];
|
|
15
|
-
let lastRefreshed = null;
|
|
16
|
-
|
|
17
|
-
// Background sync: runs periodically without blocking API requests
|
|
18
|
-
if (options.syncDir) {
|
|
19
|
-
const SYNC_INTERVAL_MS = options.syncIntervalMs || 30000;
|
|
20
|
-
const runBackgroundSync = () => {
|
|
21
|
-
syncLocalToShared(logBaseDir, options.syncDir, options.machineName).catch(err => {
|
|
22
|
-
console.warn('Background sync failed:', err.message);
|
|
23
|
-
});
|
|
24
|
-
};
|
|
25
|
-
runBackgroundSync();
|
|
26
|
-
setInterval(runBackgroundSync, SYNC_INTERVAL_MS).unref();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function refreshRecords() {
|
|
30
|
-
const now = Date.now();
|
|
31
|
-
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
32
|
-
try {
|
|
33
|
-
if (options.syncDir) {
|
|
34
|
-
cachedRecords = parseMultiMachineDirectory(options.syncDir);
|
|
35
|
-
} else {
|
|
36
|
-
cachedRecords = parseLogDirectory(logBaseDir);
|
|
37
|
-
}
|
|
38
|
-
lastRefreshed = now;
|
|
39
|
-
console.log(`Parsed ${cachedRecords.length} records${options.syncDir ? ' (sync mode)' : ''}`);
|
|
40
|
-
} catch (err) {
|
|
41
|
-
console.error('Failed to parse log directory:', err.message);
|
|
42
|
-
if (!lastRefreshed) lastRefreshed = now;
|
|
43
|
-
}
|
|
44
|
-
return cachedRecords;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function applyFilters(query) {
|
|
48
|
-
let records = filterByDateRange(refreshRecords(), query.from, query.to);
|
|
49
|
-
if (query.project) records = records.filter(r => r.project === query.project);
|
|
50
|
-
if (query.model) records = records.filter(r => r.model === query.model);
|
|
51
|
-
return records;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
router.get('/usage', (req, res) => {
|
|
55
|
-
try {
|
|
56
|
-
const records = applyFilters(req.query);
|
|
57
|
-
const granularity = req.query.granularity || autoGranularity(req.query.from, req.query.to);
|
|
58
|
-
const buckets = aggregateByTime(records, granularity);
|
|
59
|
-
const total = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_api_cost_usd: 0 };
|
|
60
|
-
for (const r of records) {
|
|
61
|
-
total.input_tokens += r.input_tokens; total.output_tokens += r.output_tokens;
|
|
62
|
-
total.cache_read_tokens += r.cache_read_tokens; total.cache_creation_tokens += r.cache_creation_tokens;
|
|
63
|
-
total.estimated_api_cost_usd += calculateRecordCost(r);
|
|
64
|
-
}
|
|
65
|
-
total.estimated_api_cost_usd = Math.round(total.estimated_api_cost_usd * 100) / 100;
|
|
66
|
-
res.json({ granularity, buckets, total });
|
|
67
|
-
} catch (err) {
|
|
68
|
-
res.status(500).json({ error: err.message, code: 'PARSE_ERROR' });
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
router.get('/models', (req, res) => { res.json({ models: aggregateByModel(applyFilters(req.query)) }); });
|
|
73
|
-
router.get('/projects', (req, res) => { res.json({ projects: aggregateByProject(applyFilters(req.query)) }); });
|
|
74
|
-
|
|
75
|
-
router.get('/sessions', (req, res) => {
|
|
76
|
-
const records = applyFilters(req.query);
|
|
77
|
-
let sessions = aggregateBySession(records);
|
|
78
|
-
const sort = req.query.sort || 'date';
|
|
79
|
-
const order = req.query.order || 'desc';
|
|
80
|
-
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));
|
|
81
|
-
sessions.sort(sortFn);
|
|
82
|
-
if (order === 'asc') sessions.reverse();
|
|
83
|
-
const totalTokens = sessions.reduce((sum, s) => sum + s.total_tokens, 0);
|
|
84
|
-
const totalCost = sessions.reduce((sum, s) => sum + s.estimated_cost_usd, 0);
|
|
85
|
-
const page = parseInt(req.query.page) || 1;
|
|
86
|
-
const limit = parseInt(req.query.limit) || 20;
|
|
87
|
-
const totalSessions = sessions.length;
|
|
88
|
-
const totalPages = Math.ceil(totalSessions / limit);
|
|
89
|
-
sessions = sessions.slice((page - 1) * limit, page * limit);
|
|
90
|
-
res.json({ sessions, pagination: { page, limit, total_sessions: totalSessions, total_pages: totalPages }, totals: { total_tokens: totalTokens, estimated_cost_usd: Math.round(totalCost * 100) / 100 } });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
router.get('/cost', (req, res) => {
|
|
94
|
-
const records = applyFilters(req.query);
|
|
95
|
-
const plan = req.query.plan || 'max5x';
|
|
96
|
-
const customPrice = req.query.customPrice ? parseFloat(req.query.customPrice) : null;
|
|
97
|
-
const subscriptionCost = customPrice || PLAN_DEFAULTS[plan] || 100;
|
|
98
|
-
let apiCost = 0;
|
|
99
|
-
for (const r of records) apiCost += calculateRecordCost(r);
|
|
100
|
-
apiCost = Math.round(apiCost * 100) / 100;
|
|
101
|
-
const dayMap = new Map();
|
|
102
|
-
for (const r of records) {
|
|
103
|
-
const d = new Date(r.timestamp);
|
|
104
|
-
const day = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
105
|
-
dayMap.set(day, (dayMap.get(day) || 0) + calculateRecordCost(r));
|
|
106
|
-
}
|
|
107
|
-
const costPerDay = Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, cost]) => {
|
|
108
|
-
const d = new Date(date);
|
|
109
|
-
const daysInMonth = new Date(d.getUTCFullYear(), d.getUTCMonth() + 1, 0).getUTCDate();
|
|
110
|
-
return { date, api_cost: Math.round(cost * 100) / 100, subscription_daily: Math.round((subscriptionCost / daysInMonth) * 100) / 100 };
|
|
111
|
-
});
|
|
112
|
-
const savings = apiCost - subscriptionCost;
|
|
113
|
-
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 });
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
117
|
-
|
|
118
|
-
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
119
|
-
router.get('/quota', async (req, res) => {
|
|
120
|
-
try {
|
|
121
|
-
const data = await quotaFetcher.fetchQuota();
|
|
122
|
-
if (data.available) {
|
|
123
|
-
try {
|
|
124
|
-
updateQuotaCycleSnapshot(
|
|
125
|
-
data,
|
|
126
|
-
logBaseDir,
|
|
127
|
-
options.machineName || os.hostname(),
|
|
128
|
-
options.snapshotDir,
|
|
129
|
-
options.syncDir
|
|
130
|
-
);
|
|
131
|
-
} catch (err) {
|
|
132
|
-
console.warn('Failed to update quota cycle snapshot:', err.message);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
res.json(data);
|
|
136
|
-
} catch (err) {
|
|
137
|
-
res.json({ available: false, error: err.message });
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
router.get('/quota-cycles', (req, res) => {
|
|
142
|
-
try {
|
|
143
|
-
const data = loadQuotaCycles(
|
|
144
|
-
options.machineName || os.hostname(),
|
|
145
|
-
options.syncDir || null,
|
|
146
|
-
options.snapshotDir
|
|
147
|
-
);
|
|
148
|
-
if (data.currentCycle) {
|
|
149
|
-
const start = new Date(data.currentCycle.start);
|
|
150
|
-
const now = new Date();
|
|
151
|
-
data.currentCycle.daysElapsed = Math.round(((now - start) / (1000 * 60 * 60 * 24)) * 10) / 10;
|
|
152
|
-
data.currentCycle.daysTotal = 7;
|
|
153
|
-
}
|
|
154
|
-
res.json(data);
|
|
155
|
-
} catch (err) {
|
|
156
|
-
res.status(500).json({ error: err.message });
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
router.get('/subscription', (req, res) => {
|
|
161
|
-
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
162
|
-
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
router.get('/status', (req, res) => {
|
|
166
|
-
refreshRecords();
|
|
167
|
-
res.json({
|
|
168
|
-
record_count: cachedRecords.length,
|
|
169
|
-
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
170
|
-
cache_ttl_ms: CACHE_TTL_MS,
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
return router;
|
|
175
|
-
}
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { parseLogDirectory, parseMultiMachineDirectory } from '../parser.js';
|
|
4
|
+
import { syncLocalToShared } from '../sync.js';
|
|
5
|
+
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
6
|
+
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
7
|
+
import { createQuotaFetcher } from '../quota.js';
|
|
8
|
+
import { getSubscriptionInfo } from '../credentials.js';
|
|
9
|
+
import { updateQuotaCycleSnapshot, loadQuotaCycles } from '../quota-cycles.js';
|
|
10
|
+
|
|
11
|
+
export function createApiRouter(logBaseDir, options = {}) {
|
|
12
|
+
const router = Router();
|
|
13
|
+
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
14
|
+
let cachedRecords = [];
|
|
15
|
+
let lastRefreshed = null;
|
|
16
|
+
|
|
17
|
+
// Background sync: runs periodically without blocking API requests
|
|
18
|
+
if (options.syncDir) {
|
|
19
|
+
const SYNC_INTERVAL_MS = options.syncIntervalMs || 30000;
|
|
20
|
+
const runBackgroundSync = () => {
|
|
21
|
+
syncLocalToShared(logBaseDir, options.syncDir, options.machineName).catch(err => {
|
|
22
|
+
console.warn('Background sync failed:', err.message);
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
runBackgroundSync();
|
|
26
|
+
setInterval(runBackgroundSync, SYNC_INTERVAL_MS).unref();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function refreshRecords() {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
32
|
+
try {
|
|
33
|
+
if (options.syncDir) {
|
|
34
|
+
cachedRecords = parseMultiMachineDirectory(options.syncDir);
|
|
35
|
+
} else {
|
|
36
|
+
cachedRecords = parseLogDirectory(logBaseDir);
|
|
37
|
+
}
|
|
38
|
+
lastRefreshed = now;
|
|
39
|
+
console.log(`Parsed ${cachedRecords.length} records${options.syncDir ? ' (sync mode)' : ''}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('Failed to parse log directory:', err.message);
|
|
42
|
+
if (!lastRefreshed) lastRefreshed = now;
|
|
43
|
+
}
|
|
44
|
+
return cachedRecords;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyFilters(query) {
|
|
48
|
+
let records = filterByDateRange(refreshRecords(), query.from, query.to);
|
|
49
|
+
if (query.project) records = records.filter(r => r.project === query.project);
|
|
50
|
+
if (query.model) records = records.filter(r => r.model === query.model);
|
|
51
|
+
return records;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
router.get('/usage', (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const records = applyFilters(req.query);
|
|
57
|
+
const granularity = req.query.granularity || autoGranularity(req.query.from, req.query.to);
|
|
58
|
+
const buckets = aggregateByTime(records, granularity);
|
|
59
|
+
const total = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_api_cost_usd: 0 };
|
|
60
|
+
for (const r of records) {
|
|
61
|
+
total.input_tokens += r.input_tokens; total.output_tokens += r.output_tokens;
|
|
62
|
+
total.cache_read_tokens += r.cache_read_tokens; total.cache_creation_tokens += r.cache_creation_tokens;
|
|
63
|
+
total.estimated_api_cost_usd += calculateRecordCost(r);
|
|
64
|
+
}
|
|
65
|
+
total.estimated_api_cost_usd = Math.round(total.estimated_api_cost_usd * 100) / 100;
|
|
66
|
+
res.json({ granularity, buckets, total });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
res.status(500).json({ error: err.message, code: 'PARSE_ERROR' });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
router.get('/models', (req, res) => { res.json({ models: aggregateByModel(applyFilters(req.query)) }); });
|
|
73
|
+
router.get('/projects', (req, res) => { res.json({ projects: aggregateByProject(applyFilters(req.query)) }); });
|
|
74
|
+
|
|
75
|
+
router.get('/sessions', (req, res) => {
|
|
76
|
+
const records = applyFilters(req.query);
|
|
77
|
+
let sessions = aggregateBySession(records);
|
|
78
|
+
const sort = req.query.sort || 'date';
|
|
79
|
+
const order = req.query.order || 'desc';
|
|
80
|
+
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));
|
|
81
|
+
sessions.sort(sortFn);
|
|
82
|
+
if (order === 'asc') sessions.reverse();
|
|
83
|
+
const totalTokens = sessions.reduce((sum, s) => sum + s.total_tokens, 0);
|
|
84
|
+
const totalCost = sessions.reduce((sum, s) => sum + s.estimated_cost_usd, 0);
|
|
85
|
+
const page = parseInt(req.query.page) || 1;
|
|
86
|
+
const limit = parseInt(req.query.limit) || 20;
|
|
87
|
+
const totalSessions = sessions.length;
|
|
88
|
+
const totalPages = Math.ceil(totalSessions / limit);
|
|
89
|
+
sessions = sessions.slice((page - 1) * limit, page * limit);
|
|
90
|
+
res.json({ sessions, pagination: { page, limit, total_sessions: totalSessions, total_pages: totalPages }, totals: { total_tokens: totalTokens, estimated_cost_usd: Math.round(totalCost * 100) / 100 } });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.get('/cost', (req, res) => {
|
|
94
|
+
const records = applyFilters(req.query);
|
|
95
|
+
const plan = req.query.plan || 'max5x';
|
|
96
|
+
const customPrice = req.query.customPrice ? parseFloat(req.query.customPrice) : null;
|
|
97
|
+
const subscriptionCost = customPrice || PLAN_DEFAULTS[plan] || 100;
|
|
98
|
+
let apiCost = 0;
|
|
99
|
+
for (const r of records) apiCost += calculateRecordCost(r);
|
|
100
|
+
apiCost = Math.round(apiCost * 100) / 100;
|
|
101
|
+
const dayMap = new Map();
|
|
102
|
+
for (const r of records) {
|
|
103
|
+
const d = new Date(r.timestamp);
|
|
104
|
+
const day = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
105
|
+
dayMap.set(day, (dayMap.get(day) || 0) + calculateRecordCost(r));
|
|
106
|
+
}
|
|
107
|
+
const costPerDay = Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, cost]) => {
|
|
108
|
+
const d = new Date(date);
|
|
109
|
+
const daysInMonth = new Date(d.getUTCFullYear(), d.getUTCMonth() + 1, 0).getUTCDate();
|
|
110
|
+
return { date, api_cost: Math.round(cost * 100) / 100, subscription_daily: Math.round((subscriptionCost / daysInMonth) * 100) / 100 };
|
|
111
|
+
});
|
|
112
|
+
const savings = apiCost - subscriptionCost;
|
|
113
|
+
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 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
117
|
+
|
|
118
|
+
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
119
|
+
router.get('/quota', async (req, res) => {
|
|
120
|
+
try {
|
|
121
|
+
const data = await quotaFetcher.fetchQuota();
|
|
122
|
+
if (data.available) {
|
|
123
|
+
try {
|
|
124
|
+
updateQuotaCycleSnapshot(
|
|
125
|
+
data,
|
|
126
|
+
logBaseDir,
|
|
127
|
+
options.machineName || os.hostname(),
|
|
128
|
+
options.snapshotDir,
|
|
129
|
+
options.syncDir
|
|
130
|
+
);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn('Failed to update quota cycle snapshot:', err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
res.json(data);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
res.json({ available: false, error: err.message });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
router.get('/quota-cycles', (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const data = loadQuotaCycles(
|
|
144
|
+
options.machineName || os.hostname(),
|
|
145
|
+
options.syncDir || null,
|
|
146
|
+
options.snapshotDir
|
|
147
|
+
);
|
|
148
|
+
if (data.currentCycle) {
|
|
149
|
+
const start = new Date(data.currentCycle.start);
|
|
150
|
+
const now = new Date();
|
|
151
|
+
data.currentCycle.daysElapsed = Math.round(((now - start) / (1000 * 60 * 60 * 24)) * 10) / 10;
|
|
152
|
+
data.currentCycle.daysTotal = 7;
|
|
153
|
+
}
|
|
154
|
+
res.json(data);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
res.status(500).json({ error: err.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
router.get('/subscription', (req, res) => {
|
|
161
|
+
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
162
|
+
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
router.get('/status', (req, res) => {
|
|
166
|
+
refreshRecords();
|
|
167
|
+
res.json({
|
|
168
|
+
record_count: cachedRecords.length,
|
|
169
|
+
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
170
|
+
cache_ttl_ms: CACHE_TTL_MS,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return router;
|
|
175
|
+
}
|
package/server/sync.js
CHANGED
|
@@ -1,69 +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
|
-
}
|
|
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
|
+
}
|