llm-usage-dashboard 0.1.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 +117 -0
- package/assets/genicon.js +90 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/assets/pricing.json.example +7 -0
- package/assets/tray.png +0 -0
- package/bin/cli.js +23 -0
- package/package.json +74 -0
- package/src/main/aggregate.js +160 -0
- package/src/main/collectors/claude.js +67 -0
- package/src/main/collectors/codex.js +73 -0
- package/src/main/collectors/jsonl.js +44 -0
- package/src/main/main.js +216 -0
- package/src/main/paths.js +50 -0
- package/src/main/pricing.js +72 -0
- package/src/main/probe.js +44 -0
- package/src/preload.js +19 -0
- package/src/renderer/app.js +186 -0
- package/src/renderer/index.html +78 -0
- package/src/renderer/styles.css +94 -0
package/src/main/main.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, shell } = require('electron');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const { collectCodex } = require('./collectors/codex');
|
|
7
|
+
const { collectClaude } = require('./collectors/claude');
|
|
8
|
+
const { buildModel } = require('./aggregate');
|
|
9
|
+
const P = require('./paths');
|
|
10
|
+
|
|
11
|
+
let win = null;
|
|
12
|
+
let tray = null;
|
|
13
|
+
let latestModel = null;
|
|
14
|
+
let refreshing = false;
|
|
15
|
+
let pending = false;
|
|
16
|
+
|
|
17
|
+
// auto-refresh cadence (user-requested): pull a fresh snapshot every 5 minutes.
|
|
18
|
+
// File-watch still gives near-instant updates between ticks; each refresh re-arms
|
|
19
|
+
// this timer so the 5-min clock counts from the last update of any kind.
|
|
20
|
+
const AUTO_REFRESH_MS = 5 * 60 * 1000;
|
|
21
|
+
let autoTimer = null;
|
|
22
|
+
let nextAutoAt = 0;
|
|
23
|
+
function armAuto() {
|
|
24
|
+
clearTimeout(autoTimer);
|
|
25
|
+
nextAutoAt = Date.now() + AUTO_REFRESH_MS;
|
|
26
|
+
autoTimer = setTimeout(() => refresh('auto-5min'), AUTO_REFRESH_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ASSET = (f) => path.join(__dirname, '..', '..', 'assets', f);
|
|
30
|
+
|
|
31
|
+
// ---- autostart (persist desired state ourselves; OS getter is unreliable in
|
|
32
|
+
// dev / with custom args, so our config file is the source of truth) -----
|
|
33
|
+
const AUTOSTART_NAME = 'AI Usage Dashboard';
|
|
34
|
+
function configPath() { return path.join(app.getPath('userData'), 'config.json'); }
|
|
35
|
+
function readConfig() {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')); } catch { return {}; }
|
|
37
|
+
}
|
|
38
|
+
function writeConfig(patch) {
|
|
39
|
+
const cfg = { ...readConfig(), ...patch };
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
|
42
|
+
fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
43
|
+
} catch (e) { console.error('[config] write failed:', e); }
|
|
44
|
+
return cfg;
|
|
45
|
+
}
|
|
46
|
+
function loginArgs() {
|
|
47
|
+
// In dev (unpackaged) we must tell electron.exe which app to run.
|
|
48
|
+
return app.isPackaged
|
|
49
|
+
? ['--hidden']
|
|
50
|
+
: [path.resolve(process.argv[1] || '.'), '--hidden'];
|
|
51
|
+
}
|
|
52
|
+
function applyAutostart(on) {
|
|
53
|
+
app.setLoginItemSettings({
|
|
54
|
+
openAtLogin: !!on,
|
|
55
|
+
path: process.execPath,
|
|
56
|
+
args: loginArgs(),
|
|
57
|
+
name: AUTOSTART_NAME,
|
|
58
|
+
});
|
|
59
|
+
writeConfig({ autostart: !!on });
|
|
60
|
+
return !!on;
|
|
61
|
+
}
|
|
62
|
+
function isAutostart() {
|
|
63
|
+
const cfg = readConfig();
|
|
64
|
+
if (typeof cfg.autostart === 'boolean') return cfg.autostart;
|
|
65
|
+
const s = app.getLoginItemSettings({ path: process.execPath, args: loginArgs() });
|
|
66
|
+
return !!(s.openAtLogin || s.executableWillLaunchAtLogin);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- data refresh -----------------------------------------------------------
|
|
70
|
+
async function refresh(reason = 'manual') {
|
|
71
|
+
if (refreshing) { pending = true; return latestModel; }
|
|
72
|
+
refreshing = true;
|
|
73
|
+
try {
|
|
74
|
+
const [codex, claude] = await Promise.all([collectCodex(), collectClaude()]);
|
|
75
|
+
latestModel = buildModel({ codex, claude });
|
|
76
|
+
latestModel.reason = reason;
|
|
77
|
+
armAuto();
|
|
78
|
+
latestModel.nextAutoRefreshAt = nextAutoAt;
|
|
79
|
+
latestModel.autoRefreshMs = AUTO_REFRESH_MS;
|
|
80
|
+
broadcast();
|
|
81
|
+
updateTray();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error('[refresh] failed:', e);
|
|
84
|
+
} finally {
|
|
85
|
+
refreshing = false;
|
|
86
|
+
if (pending) { pending = false; setTimeout(() => refresh('coalesced'), 50); }
|
|
87
|
+
}
|
|
88
|
+
return latestModel;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function broadcast() {
|
|
92
|
+
if (win && !win.isDestroyed() && latestModel) {
|
|
93
|
+
win.webContents.send('usage:update', latestModel);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fmtPct(n) { return (n == null) ? '—' : Math.round(n) + '%'; }
|
|
98
|
+
|
|
99
|
+
function updateTray() {
|
|
100
|
+
if (!tray || !latestModel) return;
|
|
101
|
+
const c = latestModel.providers.codex;
|
|
102
|
+
const cl = latestModel.providers.claude;
|
|
103
|
+
const p = c.rate && c.rate.primary ? c.rate.primary.used_percent : null;
|
|
104
|
+
const s = c.rate && c.rate.secondary ? c.rate.secondary.used_percent : null;
|
|
105
|
+
const monthCost = '$' + latestModel.totals.monthCost.toFixed(2);
|
|
106
|
+
tray.setToolTip(
|
|
107
|
+
`AI Usage Dashboard\n` +
|
|
108
|
+
`Codex 5h: ${fmtPct(p)} week: ${fmtPct(s)}\n` +
|
|
109
|
+
`Claude (max) today: ${(cl.today.total / 1e6).toFixed(1)}M tok\n` +
|
|
110
|
+
`This month est: ${monthCost}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- file watching ----------------------------------------------------------
|
|
115
|
+
let debounceTimer = null;
|
|
116
|
+
function scheduleRefresh(reason) {
|
|
117
|
+
clearTimeout(debounceTimer);
|
|
118
|
+
debounceTimer = setTimeout(() => refresh(reason), 1500);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function watchDir(dir, label) {
|
|
122
|
+
if (!P.exists(dir)) return;
|
|
123
|
+
try {
|
|
124
|
+
fs.watch(dir, { recursive: true }, () => scheduleRefresh('watch:' + label));
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.warn('[watch] could not watch', dir, e.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- window + tray ----------------------------------------------------------
|
|
131
|
+
function createWindow() {
|
|
132
|
+
if (win && !win.isDestroyed()) { win.show(); win.focus(); return; }
|
|
133
|
+
win = new BrowserWindow({
|
|
134
|
+
width: 1180,
|
|
135
|
+
height: 820,
|
|
136
|
+
minWidth: 900,
|
|
137
|
+
minHeight: 600,
|
|
138
|
+
title: 'AI Usage Dashboard',
|
|
139
|
+
icon: ASSET('icon.png'),
|
|
140
|
+
backgroundColor: '#0f1117',
|
|
141
|
+
show: false,
|
|
142
|
+
webPreferences: {
|
|
143
|
+
preload: path.join(__dirname, '..', 'preload.js'),
|
|
144
|
+
contextIsolation: true,
|
|
145
|
+
nodeIntegration: false,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
win.removeMenu();
|
|
149
|
+
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
|
150
|
+
win.once('ready-to-show', () => win.show());
|
|
151
|
+
win.on('close', (e) => {
|
|
152
|
+
// hide to tray instead of quitting
|
|
153
|
+
if (!app.isQuitting) { e.preventDefault(); win.hide(); }
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createTray() {
|
|
158
|
+
let img = nativeImage.createFromPath(ASSET('tray.png'));
|
|
159
|
+
if (img.isEmpty()) img = nativeImage.createFromPath(ASSET('icon.png'));
|
|
160
|
+
tray = new Tray(img);
|
|
161
|
+
const menu = Menu.buildFromTemplate([
|
|
162
|
+
{ label: 'Open Dashboard', click: () => createWindow() },
|
|
163
|
+
{ label: 'Refresh now', click: () => refresh('tray') },
|
|
164
|
+
{ type: 'separator' },
|
|
165
|
+
{
|
|
166
|
+
label: 'Start with Windows',
|
|
167
|
+
type: 'checkbox',
|
|
168
|
+
checked: isAutostart(),
|
|
169
|
+
click: (item) => {
|
|
170
|
+
applyAutostart(item.checked);
|
|
171
|
+
if (win && !win.isDestroyed()) win.webContents.send('autostart:changed', item.checked);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{ label: 'Open data folders', submenu: [
|
|
175
|
+
{ label: 'Codex (~/.codex/sessions)', click: () => shell.openPath(P.CODEX_SESSIONS) },
|
|
176
|
+
{ label: 'Claude (~/.claude/projects)', click: () => shell.openPath(P.CLAUDE_PROJECTS) },
|
|
177
|
+
] },
|
|
178
|
+
{ type: 'separator' },
|
|
179
|
+
{ label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } },
|
|
180
|
+
]);
|
|
181
|
+
tray.setToolTip('AI Usage Dashboard');
|
|
182
|
+
tray.setContextMenu(menu);
|
|
183
|
+
tray.on('click', () => createWindow());
|
|
184
|
+
tray.on('double-click', () => createWindow());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- IPC --------------------------------------------------------------------
|
|
188
|
+
ipcMain.handle('usage:get', async () => latestModel || (await refresh('ipc-get')));
|
|
189
|
+
ipcMain.handle('usage:refresh', async () => refresh('ipc-refresh'));
|
|
190
|
+
ipcMain.handle('app:autostart-get', () => isAutostart());
|
|
191
|
+
ipcMain.handle('app:autostart-set', (_e, on) => applyAutostart(on));
|
|
192
|
+
|
|
193
|
+
// ---- lifecycle --------------------------------------------------------------
|
|
194
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
195
|
+
if (!gotLock) {
|
|
196
|
+
app.quit();
|
|
197
|
+
} else {
|
|
198
|
+
app.on('second-instance', () => createWindow());
|
|
199
|
+
|
|
200
|
+
app.whenReady().then(async () => {
|
|
201
|
+
createTray();
|
|
202
|
+
await refresh('startup');
|
|
203
|
+
// only open the window if not launched hidden (autostart)
|
|
204
|
+
const startedHidden = process.argv.includes('--hidden');
|
|
205
|
+
if (!startedHidden) createWindow();
|
|
206
|
+
|
|
207
|
+
watchDir(P.CODEX_SESSIONS, 'codex');
|
|
208
|
+
watchDir(P.CLAUDE_PROJECTS, 'claude');
|
|
209
|
+
// 5-min auto-refresh is armed inside refresh(); the startup refresh() above
|
|
210
|
+
// already armed it. File-watch covers instant updates between ticks.
|
|
211
|
+
|
|
212
|
+
app.on('activate', () => createWindow());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
app.on('window-all-closed', () => { /* keep running in tray */ });
|
|
216
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Resolves the on-disk data sources for Codex and Claude Code.
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
|
|
9
|
+
const CODEX_DIR = process.env.CODEX_HOME || path.join(HOME, '.codex');
|
|
10
|
+
const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
|
|
11
|
+
|
|
12
|
+
const CODEX_SESSIONS = path.join(CODEX_DIR, 'sessions');
|
|
13
|
+
const CLAUDE_PROJECTS = path.join(CLAUDE_DIR, 'projects');
|
|
14
|
+
|
|
15
|
+
const CODEX_AUTH = path.join(CODEX_DIR, 'auth.json');
|
|
16
|
+
const CLAUDE_CREDS = path.join(CLAUDE_DIR, '.credentials.json');
|
|
17
|
+
|
|
18
|
+
// Normalize a cwd into a stable, comparable project key.
|
|
19
|
+
// Handles Windows extended-length prefix (\\?\) and trailing slashes.
|
|
20
|
+
function normalizeCwd(cwd) {
|
|
21
|
+
if (!cwd || typeof cwd !== 'string') return 'unknown';
|
|
22
|
+
let p = cwd.replace(/^\\\\\?\\/, ''); // strip \\?\ prefix
|
|
23
|
+
p = p.replace(/[\\/]+$/, ''); // strip trailing slash
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Friendly short name for a project path (last path segment).
|
|
28
|
+
function projectLabel(cwd) {
|
|
29
|
+
const p = normalizeCwd(cwd);
|
|
30
|
+
if (p === 'unknown') return 'unknown';
|
|
31
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
32
|
+
return parts[parts.length - 1] || p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function exists(p) {
|
|
36
|
+
try { fs.accessSync(p); return true; } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
HOME,
|
|
41
|
+
CODEX_DIR,
|
|
42
|
+
CLAUDE_DIR,
|
|
43
|
+
CODEX_SESSIONS,
|
|
44
|
+
CLAUDE_PROJECTS,
|
|
45
|
+
CODEX_AUTH,
|
|
46
|
+
CLAUDE_CREDS,
|
|
47
|
+
normalizeCwd,
|
|
48
|
+
projectLabel,
|
|
49
|
+
exists,
|
|
50
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Model pricing (USD per 1 token). Subscription users are not billed per token,
|
|
3
|
+
// so these are *reference estimates* only. Values are per 1,000,000 tokens,
|
|
4
|
+
// converted to per-token at load. Editable via assets/pricing.json (optional
|
|
5
|
+
// override placed next to the app data dir).
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// USD per 1M tokens. Keys are matched by substring against the model id.
|
|
10
|
+
const DEFAULT_PRICES_PER_M = {
|
|
11
|
+
// --- Anthropic Claude ---
|
|
12
|
+
'claude-opus': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
13
|
+
'claude-sonnet': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
14
|
+
'claude-haiku': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
|
|
15
|
+
// --- OpenAI / Codex (gpt-5 family, reference) ---
|
|
16
|
+
'gpt-5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
|
|
17
|
+
'codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
|
|
18
|
+
'o4': { input: 1.1, output: 4.4, cacheWrite: 1.1, cacheRead: 0.275 },
|
|
19
|
+
'gpt-4': { input: 2.5, output: 10, cacheWrite: 2.5, cacheRead: 1.25 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function loadOverrides(overridePath) {
|
|
23
|
+
if (overridePath && fs.existsSync(overridePath)) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(overridePath, 'utf8'));
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.warn('[pricing] failed to read override:', e.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildTable(overridePath) {
|
|
34
|
+
const merged = { ...DEFAULT_PRICES_PER_M, ...loadOverrides(overridePath) };
|
|
35
|
+
const perToken = {};
|
|
36
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
37
|
+
perToken[k] = {
|
|
38
|
+
input: (v.input || 0) / 1e6,
|
|
39
|
+
output: (v.output || 0) / 1e6,
|
|
40
|
+
cacheWrite: (v.cacheWrite ?? v.input ?? 0) / 1e6,
|
|
41
|
+
cacheRead: (v.cacheRead ?? 0) / 1e6,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return perToken;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let TABLE = buildTable(path.join(__dirname, '..', '..', 'assets', 'pricing.json'));
|
|
48
|
+
|
|
49
|
+
function priceFor(model) {
|
|
50
|
+
if (!model) return null;
|
|
51
|
+
const m = String(model).toLowerCase();
|
|
52
|
+
// longest matching key wins (more specific)
|
|
53
|
+
let best = null, bestLen = -1;
|
|
54
|
+
for (const key of Object.keys(TABLE)) {
|
|
55
|
+
if (m.includes(key) && key.length > bestLen) { best = TABLE[key]; bestLen = key.length; }
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// tokens = { input, output, cacheWrite, cacheRead }
|
|
61
|
+
function costOf(model, tokens) {
|
|
62
|
+
const p = priceFor(model);
|
|
63
|
+
if (!p) return 0;
|
|
64
|
+
return (
|
|
65
|
+
(tokens.input || 0) * p.input +
|
|
66
|
+
(tokens.output || 0) * p.output +
|
|
67
|
+
(tokens.cacheWrite || 0) * p.cacheWrite +
|
|
68
|
+
(tokens.cacheRead || 0) * p.cacheRead
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { costOf, priceFor, reload: (p) => { TABLE = buildTable(p); } };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Standalone sanity check (no Electron). Run: npm run probe
|
|
3
|
+
// Verifies the collectors + aggregation against real local data.
|
|
4
|
+
const { collectCodex } = require('./collectors/codex');
|
|
5
|
+
const { collectClaude } = require('./collectors/claude');
|
|
6
|
+
const { buildModel } = require('./aggregate');
|
|
7
|
+
|
|
8
|
+
function fmt(n) { return Number(n).toLocaleString('en-US'); }
|
|
9
|
+
function usd(n) { return '$' + Number(n).toFixed(2); }
|
|
10
|
+
function when(ms) { return ms ? new Date(ms).toLocaleString() : '—'; }
|
|
11
|
+
|
|
12
|
+
(async () => {
|
|
13
|
+
const t0 = Date.now();
|
|
14
|
+
const [codex, claude] = await Promise.all([collectCodex(), collectClaude()]);
|
|
15
|
+
const model = buildModel({ codex, claude });
|
|
16
|
+
const c = model.providers.codex;
|
|
17
|
+
const cl = model.providers.claude;
|
|
18
|
+
|
|
19
|
+
console.log('=== Codex (files: %d, plan: %s) ===', c.fileCount, c.planType || '?');
|
|
20
|
+
if (c.rate) {
|
|
21
|
+
const p = c.rate.primary, s = c.rate.secondary;
|
|
22
|
+
if (p) console.log(' 5h : %s%% used, resets %s', p.used_percent, when(p.resets_at * 1000));
|
|
23
|
+
if (s) console.log(' week: %s%% used, resets %s', s.used_percent, when(s.resets_at * 1000));
|
|
24
|
+
}
|
|
25
|
+
console.log(' 5h tokens: %s | month tokens: %s | month cost: %s',
|
|
26
|
+
fmt(c.window5h.total), fmt(c.monthly.total), usd(c.monthly.cost));
|
|
27
|
+
|
|
28
|
+
console.log('=== Claude Code (files: %d, sub: %s) ===', cl.fileCount,
|
|
29
|
+
cl.subscription ? cl.subscription.subscriptionType : '?');
|
|
30
|
+
console.log(' 5h block tokens: %s (resets %s)', fmt(cl.window5h.total), when(cl.block5hResetMs));
|
|
31
|
+
console.log(' week tokens: %s | month tokens: %s | month cost: %s',
|
|
32
|
+
fmt(cl.weekly.total), fmt(cl.monthly.total), usd(cl.monthly.cost));
|
|
33
|
+
|
|
34
|
+
console.log('=== Top projects (by all-time tokens) ===');
|
|
35
|
+
for (const p of model.projects.slice(0, 10)) {
|
|
36
|
+
console.log(' %s tokens=%s monthCost=%s providers=%s',
|
|
37
|
+
p.label.padEnd(18), fmt(p.all.total), usd(p.month.cost),
|
|
38
|
+
Object.keys(p.byProvider).join('+'));
|
|
39
|
+
}
|
|
40
|
+
console.log('=== Totals: month cost %s | month tokens %s ===',
|
|
41
|
+
usd(model.totals.monthCost), fmt(model.totals.monthTokens));
|
|
42
|
+
console.log('(probe done in %dms, events: codex=%d claude=%d)',
|
|
43
|
+
Date.now() - t0, codex.events.length, claude.events.length);
|
|
44
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
package/src/preload.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
3
|
+
|
|
4
|
+
contextBridge.exposeInMainWorld('api', {
|
|
5
|
+
get: () => ipcRenderer.invoke('usage:get'),
|
|
6
|
+
refresh: () => ipcRenderer.invoke('usage:refresh'),
|
|
7
|
+
onUpdate: (cb) => {
|
|
8
|
+
const handler = (_e, model) => cb(model);
|
|
9
|
+
ipcRenderer.on('usage:update', handler);
|
|
10
|
+
return () => ipcRenderer.removeListener('usage:update', handler);
|
|
11
|
+
},
|
|
12
|
+
getAutostart: () => ipcRenderer.invoke('app:autostart-get'),
|
|
13
|
+
setAutostart: (on) => ipcRenderer.invoke('app:autostart-set', on),
|
|
14
|
+
onAutostartChanged: (cb) => {
|
|
15
|
+
const handler = (_e, on) => cb(on);
|
|
16
|
+
ipcRenderer.on('autostart:changed', handler);
|
|
17
|
+
return () => ipcRenderer.removeListener('autostart:changed', handler);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------- formatting helpers ----------
|
|
4
|
+
function fmtTokens(n) {
|
|
5
|
+
n = n || 0;
|
|
6
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
7
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
8
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
9
|
+
return String(Math.round(n));
|
|
10
|
+
}
|
|
11
|
+
function usd(n) { return '$' + (n || 0).toFixed(2); }
|
|
12
|
+
|
|
13
|
+
function durUntil(ms) {
|
|
14
|
+
if (!ms) return '';
|
|
15
|
+
const d = ms - Date.now();
|
|
16
|
+
if (d <= 0) return '已重置';
|
|
17
|
+
const m = Math.floor(d / 60000);
|
|
18
|
+
const h = Math.floor(m / 60);
|
|
19
|
+
const days = Math.floor(h / 24);
|
|
20
|
+
if (days >= 1) return `${days}天${h % 24}小時後重置`;
|
|
21
|
+
if (h >= 1) return `${h}小時${m % 60}分後重置`;
|
|
22
|
+
return `${m}分後重置`;
|
|
23
|
+
}
|
|
24
|
+
function relTime(ms) {
|
|
25
|
+
if (!ms) return '—';
|
|
26
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
27
|
+
if (s < 60) return `${s} 秒前更新`;
|
|
28
|
+
if (s < 3600) return `${Math.floor(s / 60)} 分前更新`;
|
|
29
|
+
return new Date(ms).toLocaleString();
|
|
30
|
+
}
|
|
31
|
+
function lastActive(ms) {
|
|
32
|
+
if (!ms) return '—';
|
|
33
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
34
|
+
if (s < 60) return '剛剛';
|
|
35
|
+
if (s < 3600) return `${Math.floor(s / 60)} 分前`;
|
|
36
|
+
if (s < 86400) return `${Math.floor(s / 3600)} 小時前`;
|
|
37
|
+
return new Date(ms).toLocaleDateString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------- limit row ----------
|
|
41
|
+
function limitRow({ name, pct, resetMs, color, value }) {
|
|
42
|
+
const hot = pct != null && pct >= 85 ? ' hot' : '';
|
|
43
|
+
const right = pct != null ? `${Math.round(pct)}%` : (value || '');
|
|
44
|
+
const reset = resetMs ? `<span class="l-reset" data-reset="${resetMs}">${durUntil(resetMs)}</span>` : '<span class="l-reset"></span>';
|
|
45
|
+
const bar = pct != null
|
|
46
|
+
? `<div class="bar ${color}${hot}"><span style="width:${Math.min(100, pct)}%"></span></div>`
|
|
47
|
+
: `<div class="bar ${color}"><span style="width:100%;opacity:.18"></span></div>`;
|
|
48
|
+
return `<div class="limit">
|
|
49
|
+
<div class="limit-top"><span class="l-name">${name}</span>${reset}<span class="l-pct">${right}</span></div>
|
|
50
|
+
${bar}
|
|
51
|
+
</div>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- render ----------
|
|
55
|
+
let model = null;
|
|
56
|
+
|
|
57
|
+
function render(m) {
|
|
58
|
+
model = m;
|
|
59
|
+
const codex = m.providers.codex;
|
|
60
|
+
const claude = m.providers.claude;
|
|
61
|
+
|
|
62
|
+
// totals
|
|
63
|
+
document.getElementById('t-cost').textContent = usd(m.totals.monthCost);
|
|
64
|
+
document.getElementById('t-tokens').textContent = fmtTokens(m.totals.monthTokens);
|
|
65
|
+
document.getElementById('t-all').textContent = usd(m.totals.allCost);
|
|
66
|
+
document.getElementById('updated').textContent = relTime(m.generatedAt);
|
|
67
|
+
document.getElementById('nextauto').textContent = nextAutoText();
|
|
68
|
+
|
|
69
|
+
// ---- Codex card ----
|
|
70
|
+
document.getElementById('codex-plan').textContent = codex.planType || 'codex';
|
|
71
|
+
const cl = [];
|
|
72
|
+
if (codex.rate && codex.rate.primary) {
|
|
73
|
+
cl.push(limitRow({ name: '5 小時額度', pct: codex.rate.primary.used_percent, resetMs: codex.rate.primary.resets_at * 1000, color: 'codex' }));
|
|
74
|
+
}
|
|
75
|
+
if (codex.rate && codex.rate.secondary) {
|
|
76
|
+
cl.push(limitRow({ name: '每週額度', pct: codex.rate.secondary.used_percent, resetMs: codex.rate.secondary.resets_at * 1000, color: 'codex' }));
|
|
77
|
+
}
|
|
78
|
+
if (!cl.length) cl.push('<div class="note">尚無 Codex 額度資料(找不到 token_count 事件)。</div>');
|
|
79
|
+
document.getElementById('codex-limits').innerHTML = cl.join('');
|
|
80
|
+
document.getElementById('codex-today').textContent = fmtTokens(codex.today.total);
|
|
81
|
+
document.getElementById('codex-month').textContent = fmtTokens(codex.monthly.total);
|
|
82
|
+
document.getElementById('codex-cost').textContent = usd(codex.monthly.cost);
|
|
83
|
+
document.getElementById('codex-note').textContent = '額度 % 與重置時間為 Codex 官方記錄(精確)。花費為依模型單價估算之參考值。';
|
|
84
|
+
|
|
85
|
+
// ---- Claude card ----
|
|
86
|
+
const sub = claude.subscription;
|
|
87
|
+
document.getElementById('claude-plan').textContent = sub ? sub.subscriptionType : 'claude';
|
|
88
|
+
const cll = [];
|
|
89
|
+
cll.push(limitRow({ name: '5 小時區塊用量', pct: null, resetMs: claude.block5hResetMs, color: 'claude', value: fmtTokens(claude.window5h.total) + ' tok' }));
|
|
90
|
+
cll.push(limitRow({ name: '近 7 天用量', pct: null, resetMs: null, color: 'claude', value: fmtTokens(claude.weekly.total) + ' tok' }));
|
|
91
|
+
document.getElementById('claude-limits').innerHTML = cll.join('');
|
|
92
|
+
document.getElementById('claude-today').textContent = fmtTokens(claude.today.total);
|
|
93
|
+
document.getElementById('claude-month').textContent = fmtTokens(claude.monthly.total);
|
|
94
|
+
document.getElementById('claude-cost').textContent = usd(claude.monthly.cost);
|
|
95
|
+
|
|
96
|
+
// ---- chart (14 days, stacked) ----
|
|
97
|
+
renderChart(codex.daily, claude.daily);
|
|
98
|
+
|
|
99
|
+
// ---- projects ----
|
|
100
|
+
renderProjects(m.projects);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderChart(codexDaily, claudeDaily) {
|
|
104
|
+
const el = document.getElementById('chart');
|
|
105
|
+
const n = Math.max(codexDaily.length, claudeDaily.length);
|
|
106
|
+
let max = 1;
|
|
107
|
+
for (let i = 0; i < n; i++) {
|
|
108
|
+
const t = (codexDaily[i]?.total || 0) + (claudeDaily[i]?.total || 0);
|
|
109
|
+
if (t > max) max = t;
|
|
110
|
+
}
|
|
111
|
+
let html = '';
|
|
112
|
+
for (let i = 0; i < n; i++) {
|
|
113
|
+
const c = codexDaily[i]?.total || 0;
|
|
114
|
+
const a = claudeDaily[i]?.total || 0;
|
|
115
|
+
const label = codexDaily[i]?.label || claudeDaily[i]?.label || '';
|
|
116
|
+
const ch = Math.round((c / max) * 140);
|
|
117
|
+
const ah = Math.round((a / max) * 140);
|
|
118
|
+
const tip = `${label}\nCodex ${fmtTokens(c)} / Claude ${fmtTokens(a)}`;
|
|
119
|
+
html += `<div class="col" title="${tip}">
|
|
120
|
+
<div class="stack">
|
|
121
|
+
<div class="seg-codex" style="height:${ch}px"></div>
|
|
122
|
+
<div class="seg-claude" style="height:${ah}px"></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="x">${label.slice(3)}</div>
|
|
125
|
+
</div>`;
|
|
126
|
+
}
|
|
127
|
+
el.innerHTML = html;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderProjects(projects) {
|
|
131
|
+
const body = document.getElementById('proj-body');
|
|
132
|
+
document.getElementById('proj-count').textContent = `${projects.length} 個專案`;
|
|
133
|
+
if (!projects.length) { body.innerHTML = '<tr><td colspan="6" class="note">尚無資料</td></tr>'; return; }
|
|
134
|
+
body.innerHTML = projects.map((p) => {
|
|
135
|
+
const tags = Object.keys(p.byProvider).map((k) => `<span class="src-tag src-${k}">${k}</span>`).join('');
|
|
136
|
+
return `<tr>
|
|
137
|
+
<td title="${p.project}">${p.label}</td>
|
|
138
|
+
<td>${tags}</td>
|
|
139
|
+
<td class="num">${fmtTokens(p.all.total)}</td>
|
|
140
|
+
<td class="num">${fmtTokens(p.month.total)}</td>
|
|
141
|
+
<td class="num">${usd(p.month.cost)}</td>
|
|
142
|
+
<td>${lastActive(p.lastTs)}</td>
|
|
143
|
+
</tr>`;
|
|
144
|
+
}).join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- tickers ----------
|
|
148
|
+
function nextAutoText() {
|
|
149
|
+
if (!model || !model.nextAutoRefreshAt) return '';
|
|
150
|
+
const d = model.nextAutoRefreshAt - Date.now();
|
|
151
|
+
if (d <= 0) return '· 自動更新中…';
|
|
152
|
+
const m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
|
|
153
|
+
return `· 每 5 分自動更新(下次 ${m}:${String(s).padStart(2, '0')})`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
setInterval(() => {
|
|
157
|
+
document.querySelectorAll('.l-reset[data-reset]').forEach((el) => {
|
|
158
|
+
el.textContent = durUntil(Number(el.dataset.reset));
|
|
159
|
+
});
|
|
160
|
+
if (model) {
|
|
161
|
+
document.getElementById('updated').textContent = relTime(model.generatedAt);
|
|
162
|
+
document.getElementById('nextauto').textContent = nextAutoText();
|
|
163
|
+
}
|
|
164
|
+
}, 1000);
|
|
165
|
+
|
|
166
|
+
// ---------- wire up ----------
|
|
167
|
+
const btn = document.getElementById('refresh');
|
|
168
|
+
btn.addEventListener('click', async () => {
|
|
169
|
+
btn.disabled = true; btn.textContent = '更新中…';
|
|
170
|
+
try { const m = await window.api.refresh(); if (m) render(m); }
|
|
171
|
+
finally { btn.disabled = false; btn.textContent = '重新整理'; }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const auto = document.getElementById('autostart');
|
|
175
|
+
auto.addEventListener('change', async () => { auto.checked = await window.api.setAutostart(auto.checked); });
|
|
176
|
+
window.api.onAutostartChanged((on) => { auto.checked = on; });
|
|
177
|
+
|
|
178
|
+
window.api.onUpdate((m) => render(m));
|
|
179
|
+
|
|
180
|
+
(async () => {
|
|
181
|
+
try {
|
|
182
|
+
auto.checked = await window.api.getAutostart();
|
|
183
|
+
const m = await window.api.get();
|
|
184
|
+
if (m) render(m);
|
|
185
|
+
} catch (e) { console.error(e); }
|
|
186
|
+
})();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-Hant">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';" />
|
|
6
|
+
<title>AI Usage Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="topbar">
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<span class="logo"></span>
|
|
13
|
+
<h1>AI Usage Dashboard</h1>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="actions">
|
|
16
|
+
<label class="toggle"><input type="checkbox" id="autostart" /> <span>開機自動啟動</span></label>
|
|
17
|
+
<span id="updated" class="updated">—</span>
|
|
18
|
+
<span id="nextauto" class="updated"></span>
|
|
19
|
+
<button id="refresh" class="btn">重新整理</button>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<main>
|
|
24
|
+
<section class="totals" id="totals">
|
|
25
|
+
<div class="total-card"><div class="t-label">本月預估花費</div><div class="t-value" id="t-cost">$0.00</div></div>
|
|
26
|
+
<div class="total-card"><div class="t-label">本月總 Tokens</div><div class="t-value" id="t-tokens">0</div></div>
|
|
27
|
+
<div class="total-card"><div class="t-label">累計花費(估)</div><div class="t-value" id="t-all">$0.00</div></div>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<section class="providers">
|
|
31
|
+
<article class="card" id="card-codex">
|
|
32
|
+
<div class="card-head">
|
|
33
|
+
<h2>Codex <span class="sub">(OpenAI)</span></h2>
|
|
34
|
+
<span class="badge badge-codex" id="codex-plan">plan</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="limits" id="codex-limits"></div>
|
|
37
|
+
<div class="stat-row">
|
|
38
|
+
<div class="stat"><span class="s-label">今日</span><span class="s-val" id="codex-today">0</span></div>
|
|
39
|
+
<div class="stat"><span class="s-label">本月 tokens</span><span class="s-val" id="codex-month">0</span></div>
|
|
40
|
+
<div class="stat"><span class="s-label">本月花費</span><span class="s-val" id="codex-cost">$0</span></div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="note" id="codex-note"></div>
|
|
43
|
+
</article>
|
|
44
|
+
|
|
45
|
+
<article class="card" id="card-claude">
|
|
46
|
+
<div class="card-head">
|
|
47
|
+
<h2>Claude Code <span class="sub">(Anthropic)</span></h2>
|
|
48
|
+
<span class="badge badge-claude" id="claude-plan">plan</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="limits" id="claude-limits"></div>
|
|
51
|
+
<div class="stat-row">
|
|
52
|
+
<div class="stat"><span class="s-label">今日</span><span class="s-val" id="claude-today">0</span></div>
|
|
53
|
+
<div class="stat"><span class="s-label">本月 tokens</span><span class="s-val" id="claude-month">0</span></div>
|
|
54
|
+
<div class="stat"><span class="s-label">本月花費</span><span class="s-val" id="claude-cost">$0</span></div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="note" id="claude-note">Claude 本地不儲存官方額度,以下為依用量重建之估算視窗。</div>
|
|
57
|
+
</article>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="card chart-card">
|
|
61
|
+
<div class="card-head"><h2>近 14 天每日用量</h2><span class="legend"><i class="dot dot-codex"></i>Codex <i class="dot dot-claude"></i>Claude</span></div>
|
|
62
|
+
<div id="chart" class="chart"></div>
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<section class="card">
|
|
66
|
+
<div class="card-head"><h2>各專案用量</h2><span class="hint" id="proj-count"></span></div>
|
|
67
|
+
<table class="projects">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr><th>專案</th><th>來源</th><th class="num">累計 Tokens</th><th class="num">本月 Tokens</th><th class="num">本月花費(估)</th><th>最後活動</th></tr>
|
|
70
|
+
</thead>
|
|
71
|
+
<tbody id="proj-body"></tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</section>
|
|
74
|
+
</main>
|
|
75
|
+
|
|
76
|
+
<script src="app.js"></script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|