opencode-credit-dashboard 1.0.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 +91 -0
- package/credit-dashboard.js +1741 -0
- package/package.json +29 -0
|
@@ -0,0 +1,1741 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { hostname, homedir, networkInterfaces } from "os";
|
|
5
|
+
import { createSign, createHash } from "crypto";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const PORT = 3456;
|
|
10
|
+
function findConfigDir(start) {
|
|
11
|
+
var dir = start;
|
|
12
|
+
for (var i = 0; i < 5; i++) {
|
|
13
|
+
if (existsSync(join(dir, "opencode.json"))) return dir;
|
|
14
|
+
if (existsSync(join(dir, "config", "plugins.json"))) return dir;
|
|
15
|
+
if (existsSync(join(dir, "plugins.json"))) return dir;
|
|
16
|
+
var parent = dirname(dir);
|
|
17
|
+
if (parent === dir) break;
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
return dirname(start);
|
|
21
|
+
}
|
|
22
|
+
const CONFIG_DIR = findConfigDir(import.meta.dir);
|
|
23
|
+
const LOGS_DIR = join(CONFIG_DIR, "logs");
|
|
24
|
+
const CONFIG_FOLDER = join(CONFIG_DIR, "config");
|
|
25
|
+
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
26
|
+
const SYNC_INTERVAL_MS = 60_000;
|
|
27
|
+
const SA_PATH = join(CONFIG_DIR, "plugins", "firebase-service-account.json");
|
|
28
|
+
const FIREBASE_SCOPE = "https://www.googleapis.com/auth/firebase.database https://www.googleapis.com/auth/userinfo.email";
|
|
29
|
+
|
|
30
|
+
function getMacAddress() {
|
|
31
|
+
var nets = networkInterfaces();
|
|
32
|
+
for (var addrs of Object.values(nets)) {
|
|
33
|
+
for (var a of addrs) {
|
|
34
|
+
if (a.mac && a.mac !== "00:00:00:00:00:00" && !a.internal) return a.mac;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildDeviceId() {
|
|
41
|
+
var idFile = join(CONFIG_DIR, "device-id");
|
|
42
|
+
try {
|
|
43
|
+
if (existsSync(idFile)) {
|
|
44
|
+
var saved = readFileSync(idFile, "utf-8").trim();
|
|
45
|
+
if (saved) return saved;
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
var host = hostname().toLowerCase();
|
|
49
|
+
var mac = getMacAddress();
|
|
50
|
+
var id;
|
|
51
|
+
if (mac) {
|
|
52
|
+
var hash = createHash("sha256").update(mac).digest("hex").substring(0, 8);
|
|
53
|
+
id = (host + "-" + hash).replace(/[^a-z0-9_-]/g, "-");
|
|
54
|
+
} else {
|
|
55
|
+
id = host.replace(/[^a-z0-9_-]/g, "-");
|
|
56
|
+
}
|
|
57
|
+
try { writeFileSync(idFile, id, "utf-8"); } catch {}
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEVICE_ID = buildDeviceId();
|
|
62
|
+
var fbConnected = false;
|
|
63
|
+
var ownsServer = false;
|
|
64
|
+
|
|
65
|
+
function loadServiceAccount() {
|
|
66
|
+
if (!existsSync(SA_PATH)) return null;
|
|
67
|
+
try { return JSON.parse(readFileSync(SA_PATH, "utf-8")); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function base64url(input) {
|
|
71
|
+
var b64 = typeof input === "string"
|
|
72
|
+
? Buffer.from(input).toString("base64")
|
|
73
|
+
: input.toString("base64");
|
|
74
|
+
return b64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createSignedJWT(sa) {
|
|
78
|
+
var now = Math.floor(Date.now() / 1000);
|
|
79
|
+
var header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
80
|
+
var claims = base64url(JSON.stringify({
|
|
81
|
+
iss: sa.client_email, scope: FIREBASE_SCOPE,
|
|
82
|
+
aud: sa.token_uri, iat: now, exp: now + 3600,
|
|
83
|
+
}));
|
|
84
|
+
var unsigned = header + "." + claims;
|
|
85
|
+
var signer = createSign("RSA-SHA256");
|
|
86
|
+
signer.update(unsigned);
|
|
87
|
+
return unsigned + "." + base64url(signer.sign(sa.private_key));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var tokenCache = { token: null, expiresAt: 0 };
|
|
91
|
+
|
|
92
|
+
async function getAccessToken() {
|
|
93
|
+
var now = Date.now();
|
|
94
|
+
if (tokenCache.token && now < tokenCache.expiresAt - 60_000) return tokenCache.token;
|
|
95
|
+
var sa = loadServiceAccount();
|
|
96
|
+
if (!sa) return null;
|
|
97
|
+
try {
|
|
98
|
+
var res = await fetch(sa.token_uri, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
101
|
+
body: "grant_type=" + encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer") + "&assertion=" + encodeURIComponent(createSignedJWT(sa)),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) { return null; }
|
|
104
|
+
var data = await res.json();
|
|
105
|
+
tokenCache.token = data.access_token;
|
|
106
|
+
tokenCache.expiresAt = now + (data.expires_in || 3600) * 1000;
|
|
107
|
+
return tokenCache.token;
|
|
108
|
+
} catch (err) { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getFirebaseUrl() {
|
|
112
|
+
var sa = loadServiceAccount();
|
|
113
|
+
return sa?.project_id ? "https://" + sa.project_id + "-default-rtdb.europe-west1.firebasedatabase.app" : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sanitizeKeys(obj) {
|
|
117
|
+
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
|
118
|
+
if (Array.isArray(obj)) return obj.map(sanitizeKeys);
|
|
119
|
+
var out = {};
|
|
120
|
+
for (var [k, v] of Object.entries(obj)) {
|
|
121
|
+
var safe = k.replace(/[.$/\[\]#]/g, "~");
|
|
122
|
+
out[safe] = sanitizeKeys(v);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function pushToFirebase(snapshot) {
|
|
128
|
+
var fbUrl = getFirebaseUrl();
|
|
129
|
+
var token = await getAccessToken();
|
|
130
|
+
if (!fbUrl || !token) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
var res = await fetch(fbUrl + "/devices/" + DEVICE_ID + ".json", {
|
|
135
|
+
method: "PUT",
|
|
136
|
+
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token },
|
|
137
|
+
body: JSON.stringify(sanitizeKeys(snapshot)),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function pullAllDevices() {
|
|
146
|
+
var fbUrl = getFirebaseUrl();
|
|
147
|
+
var token = await getAccessToken();
|
|
148
|
+
if (!fbUrl || !token) return null;
|
|
149
|
+
try {
|
|
150
|
+
var res = await fetch(fbUrl + "/devices.json", { headers: { "Authorization": "Bearer " + token } });
|
|
151
|
+
if (!res.ok) return null;
|
|
152
|
+
return await res.json();
|
|
153
|
+
} catch { return null; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
var remoteCache = { data: null, fetchedAt: 0 };
|
|
157
|
+
const CACHE_TTL = 15_000;
|
|
158
|
+
|
|
159
|
+
async function getRemoteSnapshots() {
|
|
160
|
+
var now = Date.now();
|
|
161
|
+
if (!remoteCache.data || (now - remoteCache.fetchedAt) > CACHE_TTL) {
|
|
162
|
+
var remote = await pullAllDevices();
|
|
163
|
+
if (remote) { remoteCache.data = remote; remoteCache.fetchedAt = now; }
|
|
164
|
+
}
|
|
165
|
+
return remoteCache.data;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
var nicknameCache = { data: {}, fetchedAt: 0 };
|
|
169
|
+
|
|
170
|
+
async function pullNicknames() {
|
|
171
|
+
var fbUrl = getFirebaseUrl();
|
|
172
|
+
var token = await getAccessToken();
|
|
173
|
+
if (!fbUrl || !token) return {};
|
|
174
|
+
try {
|
|
175
|
+
var res = await fetch(fbUrl + "/nicknames.json", { headers: { "Authorization": "Bearer " + token } });
|
|
176
|
+
if (!res.ok) return {};
|
|
177
|
+
return (await res.json()) || {};
|
|
178
|
+
} catch { return {}; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getNicknames() {
|
|
182
|
+
var now = Date.now();
|
|
183
|
+
if (now - nicknameCache.fetchedAt > CACHE_TTL) {
|
|
184
|
+
var nicks = await pullNicknames();
|
|
185
|
+
nicknameCache.data = nicks || {};
|
|
186
|
+
nicknameCache.fetchedAt = now;
|
|
187
|
+
}
|
|
188
|
+
return nicknameCache.data;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function setNicknameOnFirebase(deviceId, nickname) {
|
|
192
|
+
var fbUrl = getFirebaseUrl();
|
|
193
|
+
var token = await getAccessToken();
|
|
194
|
+
if (!fbUrl || !token) return false;
|
|
195
|
+
try {
|
|
196
|
+
var safeId = deviceId.replace(/[.$/\[\]#]/g, "~");
|
|
197
|
+
var res = await fetch(fbUrl + "/nicknames/" + safeId + ".json", {
|
|
198
|
+
method: "PUT",
|
|
199
|
+
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token },
|
|
200
|
+
body: JSON.stringify(nickname || null),
|
|
201
|
+
});
|
|
202
|
+
if (res.ok) { nicknameCache.fetchedAt = 0; }
|
|
203
|
+
return res.ok;
|
|
204
|
+
} catch { return false; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function removeDeviceFromFirebase(deviceId) {
|
|
208
|
+
var fbUrl = getFirebaseUrl();
|
|
209
|
+
var token = await getAccessToken();
|
|
210
|
+
if (!fbUrl || !token) return false;
|
|
211
|
+
try {
|
|
212
|
+
var safeId = deviceId.replace(/[.$/\[\]#]/g, "~");
|
|
213
|
+
await fetch(fbUrl + "/devices/" + safeId + ".json", {
|
|
214
|
+
method: "DELETE", headers: { "Authorization": "Bearer " + token },
|
|
215
|
+
});
|
|
216
|
+
await fetch(fbUrl + "/nicknames/" + safeId + ".json", {
|
|
217
|
+
method: "DELETE", headers: { "Authorization": "Bearer " + token },
|
|
218
|
+
});
|
|
219
|
+
remoteCache.data = null;
|
|
220
|
+
nicknameCache.fetchedAt = 0;
|
|
221
|
+
return true;
|
|
222
|
+
} catch { return false; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function openDB() {
|
|
226
|
+
var paths = [];
|
|
227
|
+
if (process.env.OPENCODE_DIR) paths.push(join(process.env.OPENCODE_DIR, "opencode.db"));
|
|
228
|
+
if (process.env.LOCALAPPDATA) paths.push(join(process.env.LOCALAPPDATA, "opencode", "opencode.db"));
|
|
229
|
+
paths.push(DB_PATH);
|
|
230
|
+
for (var p of paths) {
|
|
231
|
+
if (existsSync(p)) {
|
|
232
|
+
try { return new Database(p, { readonly: true }); } catch {}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readJSON(p) {
|
|
239
|
+
try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return null; }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getAccountsData() {
|
|
243
|
+
var allAccounts = [];
|
|
244
|
+
var files = ["antigravity-accounts.json", "cursor-accounts.json", "zen-accounts.json"];
|
|
245
|
+
|
|
246
|
+
for (var file of files) {
|
|
247
|
+
var raw = readJSON(join(CONFIG_FOLDER, file)) || readJSON(join(CONFIG_DIR, file));
|
|
248
|
+
if (!raw?.accounts) continue;
|
|
249
|
+
var now = Date.now();
|
|
250
|
+
var mapped = raw.accounts.map(a => {
|
|
251
|
+
var rateLimits = {};
|
|
252
|
+
for (var [key, resetTs] of Object.entries(a.rateLimitResetTimes || {})) {
|
|
253
|
+
rateLimits[key] = { resetTime: resetTs, isLimited: resetTs > now };
|
|
254
|
+
}
|
|
255
|
+
var quotas = {};
|
|
256
|
+
for (var [model, q] of Object.entries(a.cachedQuota || {})) {
|
|
257
|
+
quotas[model] = {
|
|
258
|
+
remaining: q.remainingFraction,
|
|
259
|
+
resetTime: q.resetTime ? new Date(q.resetTime).getTime() : null,
|
|
260
|
+
modelCount: q.modelCount,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
email: a.email || a.username || a.id || file.split('-')[0],
|
|
265
|
+
enabled: a.enabled !== false,
|
|
266
|
+
lastUsed: a.lastUsed || a.updatedAt || 0,
|
|
267
|
+
rateLimits,
|
|
268
|
+
quotas,
|
|
269
|
+
quotaUpdatedAt: a.cachedQuotaUpdatedAt || 0,
|
|
270
|
+
provider: file.split('-')[0]
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
allAccounts = allAccounts.concat(mapped);
|
|
274
|
+
}
|
|
275
|
+
return allAccounts;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildSessionsWithCosts() {
|
|
279
|
+
var db = openDB();
|
|
280
|
+
var dbSessions = [];
|
|
281
|
+
|
|
282
|
+
if (db) {
|
|
283
|
+
try {
|
|
284
|
+
var sessions = db.query(
|
|
285
|
+
"SELECT id, title, time_created, time_updated FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC"
|
|
286
|
+
).all();
|
|
287
|
+
|
|
288
|
+
var msgRows = db.query(
|
|
289
|
+
`SELECT m.session_id,
|
|
290
|
+
m.time_created as msg_time,
|
|
291
|
+
json_extract(m.data, '$.role') as role,
|
|
292
|
+
json_extract(m.data, '$.modelID') as modelID,
|
|
293
|
+
json_extract(m.data, '$.providerID') as providerID,
|
|
294
|
+
json_extract(m.data, '$.cost') as cost,
|
|
295
|
+
json_extract(m.data, '$.tokens.input') as tok_in,
|
|
296
|
+
json_extract(m.data, '$.tokens.output') as tok_out,
|
|
297
|
+
json_extract(m.data, '$.tokens.reasoning') as tok_reason,
|
|
298
|
+
json_extract(m.data, '$.tokens.cache.read') as tok_cr,
|
|
299
|
+
json_extract(m.data, '$.tokens.cache.write') as tok_cw
|
|
300
|
+
FROM message m
|
|
301
|
+
INNER JOIN session s ON m.session_id = s.id
|
|
302
|
+
WHERE s.parent_id IS NULL AND json_extract(m.data, '$.role') = 'assistant'
|
|
303
|
+
AND (COALESCE(json_extract(m.data, '$.tokens.input'), 0) + COALESCE(json_extract(m.data, '$.tokens.output'), 0)) > 0`
|
|
304
|
+
).all();
|
|
305
|
+
|
|
306
|
+
db.close();
|
|
307
|
+
|
|
308
|
+
var msgBySession = {};
|
|
309
|
+
for (var row of msgRows) {
|
|
310
|
+
var sid = row.session_id;
|
|
311
|
+
if (!msgBySession[sid]) msgBySession[sid] = [];
|
|
312
|
+
msgBySession[sid].push(row);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
dbSessions = sessions.map(s => {
|
|
316
|
+
var msgs = msgBySession[s.id] || [];
|
|
317
|
+
var totalCost = 0;
|
|
318
|
+
var tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
319
|
+
var modelUsage = {};
|
|
320
|
+
var costByDay = {};
|
|
321
|
+
|
|
322
|
+
for (var msg of msgs) {
|
|
323
|
+
var cost = msg.cost || 0;
|
|
324
|
+
totalCost += cost;
|
|
325
|
+
tokens.input += msg.tok_in || 0;
|
|
326
|
+
tokens.output += msg.tok_out || 0;
|
|
327
|
+
tokens.reasoning += msg.tok_reason || 0;
|
|
328
|
+
tokens.cacheRead += msg.tok_cr || 0;
|
|
329
|
+
tokens.cacheWrite += msg.tok_cw || 0;
|
|
330
|
+
|
|
331
|
+
var mid = msg.modelID || "unknown";
|
|
332
|
+
if (!modelUsage[mid]) modelUsage[mid] = { cost: 0, tokens: { input: 0, output: 0, reasoning: 0 }, provider: msg.providerID || "", count: 0 };
|
|
333
|
+
modelUsage[mid].cost += cost;
|
|
334
|
+
modelUsage[mid].count += 1;
|
|
335
|
+
modelUsage[mid].tokens.input += msg.tok_in || 0;
|
|
336
|
+
modelUsage[mid].tokens.output += msg.tok_out || 0;
|
|
337
|
+
modelUsage[mid].tokens.reasoning += msg.tok_reason || 0;
|
|
338
|
+
|
|
339
|
+
var mTs = msg.msg_time || s.time_updated || 0;
|
|
340
|
+
if (mTs) {
|
|
341
|
+
var md = new Date(mTs);
|
|
342
|
+
var dayKey = md.getFullYear() + "-" + (md.getMonth() + 1 < 10 ? "0" : "") + (md.getMonth() + 1) + "-" + (md.getDate() < 10 ? "0" : "") + md.getDate();
|
|
343
|
+
if (!costByDay[dayKey]) costByDay[dayKey] = { cost: 0, tokens: 0, tokens_in: 0, tokens_out: 0, tokens_reason: 0, msgs: 0 };
|
|
344
|
+
costByDay[dayKey].cost += cost;
|
|
345
|
+
costByDay[dayKey].tokens += (msg.tok_in || 0) + (msg.tok_out || 0) + (msg.tok_reason || 0);
|
|
346
|
+
costByDay[dayKey].tokens_in += msg.tok_in || 0;
|
|
347
|
+
costByDay[dayKey].tokens_out += msg.tok_out || 0;
|
|
348
|
+
costByDay[dayKey].tokens_reason += msg.tok_reason || 0;
|
|
349
|
+
costByDay[dayKey].msgs += 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
id: s.id,
|
|
355
|
+
title: s.title || "Untitled",
|
|
356
|
+
created: s.time_created || 0,
|
|
357
|
+
updated: s.time_updated || 0,
|
|
358
|
+
cost: totalCost,
|
|
359
|
+
tokens,
|
|
360
|
+
modelUsage,
|
|
361
|
+
costByDay,
|
|
362
|
+
messageCount: msgs.length || s.messageCount || 0,
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
try { db.close(); } catch {}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
var legacySessions = [];
|
|
371
|
+
var sessionDir = join(CONFIG_DIR, "data", "storage", "session");
|
|
372
|
+
var msgDirBase = join(CONFIG_DIR, "data", "storage", "message");
|
|
373
|
+
|
|
374
|
+
if (existsSync(sessionDir)) {
|
|
375
|
+
try {
|
|
376
|
+
for (var projectDir of readdirSync(sessionDir)) {
|
|
377
|
+
var fullDir = join(sessionDir, projectDir);
|
|
378
|
+
try {
|
|
379
|
+
for (var file of readdirSync(fullDir)) {
|
|
380
|
+
if (!file.endsWith(".json")) continue;
|
|
381
|
+
var s = readJSON(join(fullDir, file));
|
|
382
|
+
if (!s?.id || s.parentID) continue;
|
|
383
|
+
|
|
384
|
+
if (dbSessions.some(ds => ds.id === s.id)) continue;
|
|
385
|
+
if (legacySessions.some(ls => ls.id === s.id)) continue;
|
|
386
|
+
|
|
387
|
+
var msgs = [];
|
|
388
|
+
var msgDir = join(msgDirBase, s.id);
|
|
389
|
+
if (existsSync(msgDir)) {
|
|
390
|
+
try {
|
|
391
|
+
for (var mFile of readdirSync(msgDir)) {
|
|
392
|
+
if (!mFile.endsWith(".json")) continue;
|
|
393
|
+
var m = readJSON(join(msgDir, mFile));
|
|
394
|
+
if (m?.id && m.role === "assistant" && ((m.tokens?.input || 0) + (m.tokens?.output || 0)) > 0) msgs.push(m);
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
var totalCost = 0;
|
|
400
|
+
var tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
401
|
+
var modelUsage = {};
|
|
402
|
+
var costByDay = {};
|
|
403
|
+
|
|
404
|
+
for (var msg of msgs) {
|
|
405
|
+
var cost = msg.cost || 0;
|
|
406
|
+
totalCost += cost;
|
|
407
|
+
var msgTokIn = 0, msgTokOut = 0, msgTokR = 0;
|
|
408
|
+
if (msg.tokens) {
|
|
409
|
+
msgTokIn = msg.tokens.input || 0;
|
|
410
|
+
msgTokOut = msg.tokens.output || 0;
|
|
411
|
+
msgTokR = msg.tokens.reasoning || 0;
|
|
412
|
+
tokens.input += msgTokIn;
|
|
413
|
+
tokens.output += msgTokOut;
|
|
414
|
+
tokens.reasoning += msgTokR;
|
|
415
|
+
tokens.cacheRead += msg.tokens.cache?.read || 0;
|
|
416
|
+
tokens.cacheWrite += msg.tokens.cache?.write || 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
var mid = (msg.modelID || "unknown").replace(/[.#$[]]/g, "_");
|
|
420
|
+
if (!modelUsage[mid]) modelUsage[mid] = { cost: 0, tokens: { input: 0, output: 0, reasoning: 0 }, provider: msg.providerID || "", count: 0 };
|
|
421
|
+
modelUsage[mid].cost += cost;
|
|
422
|
+
modelUsage[mid].count += 1;
|
|
423
|
+
if (msg.tokens) {
|
|
424
|
+
modelUsage[mid].tokens.input += msgTokIn;
|
|
425
|
+
modelUsage[mid].tokens.output += msgTokOut;
|
|
426
|
+
modelUsage[mid].tokens.reasoning += msgTokR;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
var mTs = msg.time?.created || s.time?.updated || 0;
|
|
430
|
+
if (mTs) {
|
|
431
|
+
var md = new Date(mTs);
|
|
432
|
+
var dayKey = md.getFullYear() + "-" + (md.getMonth() + 1 < 10 ? "0" : "") + (md.getMonth() + 1) + "-" + (md.getDate() < 10 ? "0" : "") + md.getDate();
|
|
433
|
+
if (!costByDay[dayKey]) costByDay[dayKey] = { cost: 0, tokens: 0, tokens_in: 0, tokens_out: 0, tokens_reason: 0, msgs: 0 };
|
|
434
|
+
costByDay[dayKey].cost += cost;
|
|
435
|
+
costByDay[dayKey].tokens += msgTokIn + msgTokOut + msgTokR;
|
|
436
|
+
costByDay[dayKey].tokens_in += msgTokIn;
|
|
437
|
+
costByDay[dayKey].tokens_out += msgTokOut;
|
|
438
|
+
costByDay[dayKey].tokens_reason += msgTokR;
|
|
439
|
+
costByDay[dayKey].msgs += 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
legacySessions.push({
|
|
444
|
+
id: s.id,
|
|
445
|
+
title: s.title || "Untitled",
|
|
446
|
+
created: s.time?.created || 0,
|
|
447
|
+
updated: s.time?.updated || 0,
|
|
448
|
+
cost: totalCost,
|
|
449
|
+
tokens,
|
|
450
|
+
modelUsage,
|
|
451
|
+
costByDay,
|
|
452
|
+
messageCount: msgs.length || s.messageCount || 0,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
} catch {}
|
|
456
|
+
}
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
var allSessions = [...dbSessions, ...legacySessions];
|
|
461
|
+
allSessions.sort((a, b) => b.updated - a.updated);
|
|
462
|
+
return allSessions;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function buildModelSummary(sessions) {
|
|
466
|
+
var models = {};
|
|
467
|
+
for (var s of sessions) {
|
|
468
|
+
for (var [mid, u] of Object.entries(s.modelUsage || {})) {
|
|
469
|
+
if (!models[mid]) models[mid] = { cost: 0, tokens: { input: 0, output: 0, reasoning: 0 }, provider: u.provider, sessionCount: 0, msgCount: 0 };
|
|
470
|
+
models[mid].cost += u.cost;
|
|
471
|
+
models[mid].tokens.input += u.tokens.input;
|
|
472
|
+
models[mid].tokens.output += u.tokens.output;
|
|
473
|
+
models[mid].tokens.reasoning += u.tokens.reasoning;
|
|
474
|
+
models[mid].sessionCount += 1;
|
|
475
|
+
models[mid].msgCount += u.count;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return models;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildSnapshot() {
|
|
482
|
+
var accounts = getAccountsData();
|
|
483
|
+
var sessions = buildSessionsWithCosts();
|
|
484
|
+
var models = buildModelSummary(sessions);
|
|
485
|
+
var costByDay = {};
|
|
486
|
+
for (var i = 0; i < sessions.length; i++) {
|
|
487
|
+
var cbd = sessions[i].costByDay;
|
|
488
|
+
if (cbd) {
|
|
489
|
+
for (var dk of Object.keys(cbd)) {
|
|
490
|
+
if (!costByDay[dk]) costByDay[dk] = { cost: 0, tokens: 0, tokens_in: 0, tokens_out: 0, tokens_reason: 0, msgs: 0 };
|
|
491
|
+
costByDay[dk].cost += cbd[dk].cost || 0;
|
|
492
|
+
costByDay[dk].tokens += cbd[dk].tokens || 0;
|
|
493
|
+
costByDay[dk].tokens_in += cbd[dk].tokens_in || 0;
|
|
494
|
+
costByDay[dk].tokens_out += cbd[dk].tokens_out || 0;
|
|
495
|
+
costByDay[dk].tokens_reason += cbd[dk].tokens_reason || 0;
|
|
496
|
+
costByDay[dk].msgs += cbd[dk].msgs || 0;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { device: DEVICE_ID, updatedAt: Date.now(), accounts, sessions: sessions.slice(0, 50), models, costByDay };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
var snapshotCache = { data: null, builtAt: 0 };
|
|
504
|
+
const SNAPSHOT_TTL = 5_000;
|
|
505
|
+
|
|
506
|
+
function getCachedSnapshot() {
|
|
507
|
+
var now = Date.now();
|
|
508
|
+
if (!snapshotCache.data || (now - snapshotCache.builtAt) > SNAPSHOT_TTL) {
|
|
509
|
+
snapshotCache.data = buildSnapshot();
|
|
510
|
+
snapshotCache.builtAt = now;
|
|
511
|
+
}
|
|
512
|
+
return snapshotCache.data;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function mergeSnapshots(local, remotes) {
|
|
516
|
+
var allDevices = [{ ...local, isLocal: true }];
|
|
517
|
+
if (remotes) {
|
|
518
|
+
for (var [id, snap] of Object.entries(remotes)) {
|
|
519
|
+
if (id !== DEVICE_ID) allDevices.push({ ...snap, isLocal: false });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
allDevices.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
523
|
+
var accounts = allDevices[0].accounts || [];
|
|
524
|
+
|
|
525
|
+
var byId = {};
|
|
526
|
+
for (var dev of allDevices) {
|
|
527
|
+
for (var s of (dev.sessions || [])) {
|
|
528
|
+
if (!byId[s.id]) {
|
|
529
|
+
byId[s.id] = { ...s, device: dev.device, isLocal: dev.isLocal, onDevices: [dev.device] };
|
|
530
|
+
} else {
|
|
531
|
+
if (byId[s.id].onDevices.indexOf(dev.device) === -1) byId[s.id].onDevices.push(dev.device);
|
|
532
|
+
if ((s.updated || 0) > (byId[s.id].updated || 0)) {
|
|
533
|
+
var prevDevices = byId[s.id].onDevices;
|
|
534
|
+
byId[s.id] = { ...s, device: dev.device, isLocal: dev.isLocal, onDevices: prevDevices };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
var sessions = Object.values(byId).sort((a, b) => (b.updated || 0) - (a.updated || 0));
|
|
540
|
+
|
|
541
|
+
var models = {};
|
|
542
|
+
for (var dev of allDevices) {
|
|
543
|
+
for (var [mid, u] of Object.entries(dev.models || {})) {
|
|
544
|
+
if (!models[mid]) models[mid] = { cost: 0, tokens: { input: 0, output: 0, reasoning: 0 }, provider: u.provider, sessionCount: 0, msgCount: 0 };
|
|
545
|
+
models[mid].cost += u.cost;
|
|
546
|
+
models[mid].tokens.input += u.tokens.input;
|
|
547
|
+
models[mid].tokens.output += u.tokens.output;
|
|
548
|
+
models[mid].tokens.reasoning += u.tokens.reasoning;
|
|
549
|
+
models[mid].sessionCount += u.sessionCount;
|
|
550
|
+
models[mid].msgCount += u.msgCount;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
var devices = allDevices.map(function(d) {
|
|
555
|
+
var tCost = 0, tTok = 0, tMsg = 0;
|
|
556
|
+
for (var s of (d.sessions || [])) {
|
|
557
|
+
tCost += s.cost || 0;
|
|
558
|
+
tTok += (s.tokens?.input || 0) + (s.tokens?.output || 0) + (s.tokens?.reasoning || 0);
|
|
559
|
+
tMsg += s.messageCount || 0;
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
device: d.device, updatedAt: d.updatedAt, isLocal: d.isLocal,
|
|
563
|
+
sessionCount: (d.sessions || []).length, totalCost: tCost, totalTokens: tTok, totalMessages: tMsg,
|
|
564
|
+
costByDay: d.costByDay || {},
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
var costByDay = {};
|
|
569
|
+
for (var dev of allDevices) {
|
|
570
|
+
var dcbd = dev.costByDay || {};
|
|
571
|
+
for (var dk of Object.keys(dcbd)) {
|
|
572
|
+
if (!costByDay[dk]) costByDay[dk] = { cost: 0, tokens: 0, tokens_in: 0, tokens_out: 0, tokens_reason: 0, msgs: 0 };
|
|
573
|
+
costByDay[dk].cost += dcbd[dk].cost || 0;
|
|
574
|
+
costByDay[dk].tokens += dcbd[dk].tokens || 0;
|
|
575
|
+
costByDay[dk].tokens_in += dcbd[dk].tokens_in || 0;
|
|
576
|
+
costByDay[dk].tokens_out += dcbd[dk].tokens_out || 0;
|
|
577
|
+
costByDay[dk].tokens_reason += dcbd[dk].tokens_reason || 0;
|
|
578
|
+
costByDay[dk].msgs += dcbd[dk].msgs || 0;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return { accounts, sessions, models, devices, costByDay };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
var HTML = `<!DOCTYPE html>
|
|
586
|
+
<html lang="en">
|
|
587
|
+
<head>
|
|
588
|
+
<meta charset="UTF-8">
|
|
589
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
590
|
+
<title>OpenCode Analytics</title>
|
|
591
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📊</text></svg>">
|
|
592
|
+
<style>
|
|
593
|
+
:root{--bg:#0a0f16;--bg2:#0d1117;--card:#151b23;--card2:#1a2230;--hover:#1e2a38;--border:#1e2a38;--border2:#30363d;--text:#e6edf3;--text2:#b1bac4;--dim:#8b949e;--muted:#484f58;--green:#3fb950;--green2:#238636;--yellow:#d29922;--red:#f85149;--blue:#58a6ff;--purple:#bc8cff;--cyan:#56d4dd;--r:12px;--r-sm:8px;--shadow:0 1px 3px rgba(0,0,0,.4);--shadow-lg:0 4px 12px rgba(0,0,0,.5)}
|
|
594
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
595
|
+
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
|
596
|
+
#app{max-width:1320px;margin:0 auto;padding:32px 24px}
|
|
597
|
+
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--border)}
|
|
598
|
+
h1{font-size:22px;font-weight:700;letter-spacing:-.5px;display:flex;align-items:baseline;gap:10px}
|
|
599
|
+
h1 small{color:var(--dim);font-weight:400;font-size:12px;font-variant-numeric:tabular-nums}
|
|
600
|
+
.hdr-r{display:flex;align-items:center;gap:10px}
|
|
601
|
+
.sync-badge{font-size:11px;padding:3px 10px;border-radius:12px;border:1px solid var(--border);font-weight:500}
|
|
602
|
+
.sync-badge.y{border-color:var(--green2);color:var(--green);background:rgba(63,185,80,.06)}
|
|
603
|
+
.sync-badge.n{color:var(--muted)}
|
|
604
|
+
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--green);animation:pulse 2s infinite}
|
|
605
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
606
|
+
.meta{color:var(--dim);font-size:12px}
|
|
607
|
+
.btn{background:var(--card);border:1px solid var(--border2);color:var(--text2);padding:6px 14px;border-radius:var(--r-sm);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s ease}
|
|
608
|
+
.btn:hover{background:var(--hover);border-color:var(--muted);color:var(--text)}
|
|
609
|
+
.btn.on{border-color:var(--green2);color:var(--green);background:rgba(63,185,80,.08)}
|
|
610
|
+
.devices{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:20px}
|
|
611
|
+
.chip{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:16px;font-size:11px;border:1px solid var(--border);background:var(--card);font-weight:500;transition:border-color .15s}
|
|
612
|
+
.chip:hover{border-color:var(--border2)}
|
|
613
|
+
.chip.local{border-color:var(--green2);background:rgba(63,185,80,.04)}
|
|
614
|
+
.ddot{width:6px;height:6px;border-radius:50%;display:inline-block}
|
|
615
|
+
.ddot.on{background:var(--green)}.ddot.stale{background:var(--yellow)}.ddot.off{background:var(--red)}
|
|
616
|
+
.summary-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px}
|
|
617
|
+
.sum-card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px 22px;position:relative;overflow:hidden;box-shadow:var(--shadow);transition:border-color .2s,box-shadow .2s}
|
|
618
|
+
.sum-card:hover{border-color:var(--border2);box-shadow:var(--shadow-lg)}
|
|
619
|
+
.sum-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px}
|
|
620
|
+
.sum-card.c1::before{background:linear-gradient(90deg,var(--green),var(--cyan))}
|
|
621
|
+
.sum-card.c2::before{background:linear-gradient(90deg,var(--blue),var(--purple))}
|
|
622
|
+
.sum-card.c3::before{background:linear-gradient(90deg,var(--yellow),var(--red))}
|
|
623
|
+
.sum-card h3{font-size:11px;text-transform:uppercase;letter-spacing:.8px;color:var(--dim);margin-bottom:6px;font-weight:600}
|
|
624
|
+
.sum-card .val{font-size:28px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
|
|
625
|
+
.sum-card .sub{font-size:11px;color:var(--dim);margin-top:6px;font-variant-numeric:tabular-nums}
|
|
626
|
+
.sum-card .sub span{margin-right:12px}
|
|
627
|
+
.graph-section{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;margin-bottom:24px;box-shadow:var(--shadow)}
|
|
628
|
+
.graph-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
|
629
|
+
.graph-hdr h2{font-size:13px;font-weight:600;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
|
|
630
|
+
.graph-outer{position:relative}
|
|
631
|
+
.graph-area{width:100%;height:240px}
|
|
632
|
+
.graph-tip{display:none;position:absolute;pointer-events:none;background:var(--card2);border:1px solid var(--border2);border-radius:6px;padding:5px 12px;font-size:11px;color:var(--text);white-space:nowrap;z-index:20;box-shadow:var(--shadow-lg);font-variant-numeric:tabular-nums}
|
|
633
|
+
.graph-legend{display:flex;flex-wrap:wrap;gap:14px;padding:12px 0 0;justify-content:center}
|
|
634
|
+
.legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);font-weight:500}
|
|
635
|
+
.legend-dot{width:10px;height:10px;border-radius:3px;display:inline-block}
|
|
636
|
+
.empty-graph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:13px}
|
|
637
|
+
.tabs{display:flex;gap:4px;margin-bottom:20px;background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:4px;width:fit-content}
|
|
638
|
+
.tab{background:0;border:0;color:var(--dim);padding:7px 20px;border-radius:var(--r-sm);cursor:pointer;font-size:13px;font-weight:500;transition:all .15s ease}
|
|
639
|
+
.tab:hover{color:var(--text)}
|
|
640
|
+
.tab.on{color:var(--text);background:var(--hover);box-shadow:0 1px 2px rgba(0,0,0,.3)}
|
|
641
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px;margin-bottom:24px}
|
|
642
|
+
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px;transition:all .2s ease;box-shadow:var(--shadow)}
|
|
643
|
+
.card:hover{border-color:var(--border2);box-shadow:var(--shadow-lg);transform:translateY(-1px)}
|
|
644
|
+
.card h3{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-bottom:6px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
645
|
+
.card .val{font-size:24px;font-weight:700;margin-bottom:6px;font-variant-numeric:tabular-nums;letter-spacing:-.3px}
|
|
646
|
+
.card .bar-t{width:100%;height:4px;background:var(--border);border-radius:4px;overflow:hidden;margin-bottom:8px}
|
|
647
|
+
.card .bar-f{height:100%;border-radius:4px;transition:width .5s ease}
|
|
648
|
+
.card .sub{font-size:11px;color:var(--dim);font-variant-numeric:tabular-nums}
|
|
649
|
+
.card .sub span{margin-right:10px}
|
|
650
|
+
.section-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
|
651
|
+
.section-hdr h2{font-size:16px;font-weight:600;letter-spacing:-.2px}
|
|
652
|
+
.section-hdr .controls{display:flex;gap:8px;align-items:center}
|
|
653
|
+
.filter{background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:var(--r-sm);font-size:12px;outline:0;transition:border-color .15s ease;-webkit-appearance:none;appearance:none}
|
|
654
|
+
.filter:focus{border-color:var(--blue)}
|
|
655
|
+
select.filter{padding-right:28px;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238b949e' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right 6px center;background-repeat:no-repeat;background-size:16px}
|
|
656
|
+
.tw{background:var(--card);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
|
|
657
|
+
table{width:100%;border-collapse:collapse}
|
|
658
|
+
th{text-align:left;padding:10px 16px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);border-bottom:1px solid var(--border);cursor:pointer;user-select:none;white-space:nowrap;font-weight:600;background:var(--bg2);transition:color .15s}
|
|
659
|
+
th:hover{color:var(--text)}
|
|
660
|
+
td{padding:10px 16px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle}
|
|
661
|
+
tr:last-child td{border-bottom:0}
|
|
662
|
+
tr:hover{background:var(--hover)}
|
|
663
|
+
tr.dis{opacity:.35}
|
|
664
|
+
tr.totals td{font-weight:600;border-top:2px solid var(--border2);color:var(--dim);background:transparent}
|
|
665
|
+
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;background:var(--border);margin-right:4px;font-variant-numeric:tabular-nums;font-weight:500}
|
|
666
|
+
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;border:1px solid var(--border);color:var(--dim);font-weight:500}
|
|
667
|
+
.badge.local{border-color:var(--green2);color:var(--green)}
|
|
668
|
+
.badge.prov{border-color:var(--purple);color:var(--purple);margin-left:4px}
|
|
669
|
+
.lim-y{color:var(--red);font-weight:600}
|
|
670
|
+
.lim-n{color:var(--green)}
|
|
671
|
+
.qbar{flex:1;height:4px;background:var(--border);border-radius:4px;overflow:hidden;min-width:40px}
|
|
672
|
+
.qbar-f{height:100%;border-radius:4px}
|
|
673
|
+
.qpct{font-size:11px;min-width:32px;text-align:right;font-variant-numeric:tabular-nums}
|
|
674
|
+
.cost{font-variant-numeric:tabular-nums}
|
|
675
|
+
.empty{text-align:center;padding:48px;color:var(--dim);font-size:13px}
|
|
676
|
+
.nick-input{background:var(--bg2);border:1px solid var(--blue);color:var(--text);padding:4px 10px;border-radius:6px;font-size:12px;outline:0;width:140px}
|
|
677
|
+
.btn-sm{background:var(--card);border:1px solid var(--border2);color:var(--text2);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:500;transition:all .15s}
|
|
678
|
+
.btn-sm:hover{background:var(--hover);border-color:var(--muted)}
|
|
679
|
+
.btn-sm.danger{color:var(--red);border-color:var(--red)}
|
|
680
|
+
.btn-sm.danger:hover{background:rgba(248,81,73,.1)}
|
|
681
|
+
.btn-sm.success{color:var(--green);border-color:var(--green)}
|
|
682
|
+
.btn-sm.success:hover{background:rgba(63,185,80,.1)}
|
|
683
|
+
.dev-nick{color:var(--cyan);font-size:12px;cursor:pointer;border-bottom:1px dashed var(--muted);transition:border-color .15s}
|
|
684
|
+
.dev-nick:hover{border-color:var(--cyan)}
|
|
685
|
+
footer{text-align:center;padding:24px 0 8px;color:var(--muted);font-size:11px;border-top:1px solid var(--border);margin-top:32px}
|
|
686
|
+
@media(max-width:768px){.summary-row{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.tw{overflow-x:auto}header{flex-direction:column;align-items:flex-start;gap:10px}.tabs{width:100%}.tab{flex:1;text-align:center}}
|
|
687
|
+
.quota-status{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
|
688
|
+
.q-avail{font-size:24px;font-weight:700;margin-bottom:8px;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
|
|
689
|
+
</style>
|
|
690
|
+
</head>
|
|
691
|
+
<body>
|
|
692
|
+
<div id="app">
|
|
693
|
+
<header>
|
|
694
|
+
<h1>OpenCode Analytics<small id="info"></small></h1>
|
|
695
|
+
<div class="hdr-r">
|
|
696
|
+
<span class="sync-badge n" id="sync"></span>
|
|
697
|
+
<span class="meta"><span class="dot" id="dot"></span><span id="when">Loading...</span></span>
|
|
698
|
+
<button class="btn on" id="ar" onclick="toggleAR()">Auto-refresh</button>
|
|
699
|
+
<button class="btn" onclick="load()">Refresh</button>
|
|
700
|
+
</div>
|
|
701
|
+
</header>
|
|
702
|
+
<div class="devices" id="devs"></div>
|
|
703
|
+
<div class="summary-row" id="summary"></div>
|
|
704
|
+
<div class="graph-section">
|
|
705
|
+
<div class="graph-hdr">
|
|
706
|
+
<h2>Usage Over Time</h2>
|
|
707
|
+
<div style="display:flex;gap:8px">
|
|
708
|
+
<select id="gd" class="filter" onchange="rG();rSummary()"><option value="all">All devices</option></select>
|
|
709
|
+
<select id="gf" class="filter" onchange="rG();rSummary()">
|
|
710
|
+
<option value="7">7 Days</option>
|
|
711
|
+
<option value="30">30 Days</option>
|
|
712
|
+
<option value="365" selected>Year</option>
|
|
713
|
+
<option value="all">All Time</option>
|
|
714
|
+
</select>
|
|
715
|
+
<select id="gm" class="filter" onchange="rG()">
|
|
716
|
+
<option value="tokens">Tokens</option>
|
|
717
|
+
<option value="cost">Cost</option>
|
|
718
|
+
<option value="msgs">Messages</option>
|
|
719
|
+
</select>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
<div class="graph-outer">
|
|
723
|
+
<div class="graph-area" id="graph-container"></div>
|
|
724
|
+
<div class="graph-tip" id="graph-tip"></div>
|
|
725
|
+
</div>
|
|
726
|
+
<div class="graph-legend" id="graph-legend"></div>
|
|
727
|
+
</div>
|
|
728
|
+
<div class="tabs" id="tabs">
|
|
729
|
+
<button class="tab on" onclick="go('models')">Models</button>
|
|
730
|
+
<button class="tab" onclick="go('accounts')">Accounts</button>
|
|
731
|
+
<button class="tab" onclick="go('sessions')">Sessions</button>
|
|
732
|
+
<button class="tab" onclick="go('devices')">Devices</button>
|
|
733
|
+
</div>
|
|
734
|
+
<div id="p-models">
|
|
735
|
+
<div class="section-hdr"><h2>Models <small id="msub" style="color:var(--dim)"></small> <small style="color:var(--muted);font-weight:400;font-size:11px">(All-Time)</small></h2>
|
|
736
|
+
<div class="controls">
|
|
737
|
+
<select class="filter" id="md" onchange="rM()"><option value="all">All devices</option></select>
|
|
738
|
+
<select class="filter" id="mf" onchange="rM()" style="width:140px">
|
|
739
|
+
<option value="tokens">Sort by Tokens</option>
|
|
740
|
+
<option value="cost">Sort by Cost</option>
|
|
741
|
+
<option value="msgs">Sort by Messages</option>
|
|
742
|
+
<option value="name">Sort by Name</option>
|
|
743
|
+
</select>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
<div class="grid" id="mcards"></div>
|
|
747
|
+
</div>
|
|
748
|
+
<div id="p-accounts" style="display:none">
|
|
749
|
+
<div class="section-hdr">
|
|
750
|
+
<h2>Accounts <small id="asub" style="color:var(--dim)"></small></h2>
|
|
751
|
+
<div class="controls">
|
|
752
|
+
<button class="btn-sm success" onclick="toggleAllAccounts(true)">Enable All</button>
|
|
753
|
+
<button class="btn-sm danger" onclick="toggleAllAccounts(false)">Disable All</button>
|
|
754
|
+
<input class="filter" placeholder="Filter accounts..." id="af" oninput="rA()" style="width:200px">
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div class="summary-row" id="quotas"></div>
|
|
758
|
+
<div class="tw"><table><thead><tr>
|
|
759
|
+
<th onclick="sA('email')">Account</th>
|
|
760
|
+
<th onclick="sA('enabled')">Status</th>
|
|
761
|
+
<th onclick="sA('credits')">Quota Remaining</th>
|
|
762
|
+
<th onclick="sA('rateLimited')">Rate Limited</th>
|
|
763
|
+
<th onclick="sA('lastUsed')">Last Used</th>
|
|
764
|
+
<th>Actions</th>
|
|
765
|
+
</tr></thead><tbody id="atb"></tbody></table></div>
|
|
766
|
+
</div>
|
|
767
|
+
<div id="p-sessions" style="display:none">
|
|
768
|
+
<div class="section-hdr"><h2>Sessions <small id="ssub" style="color:var(--dim)"></small></h2>
|
|
769
|
+
<select class="filter" id="df" onchange="rS()" style="width:160px"><option value="all">All devices</option></select>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="tw"><table><thead><tr>
|
|
772
|
+
<th onclick="sS('title')">Title</th>
|
|
773
|
+
<th onclick="sS('device')">Device</th>
|
|
774
|
+
<th onclick="sS('cost')">Cost</th>
|
|
775
|
+
<th onclick="sS('tokens')">Tokens</th>
|
|
776
|
+
<th onclick="sS('updated')">Updated</th>
|
|
777
|
+
</tr></thead><tbody id="stb"></tbody></table></div>
|
|
778
|
+
</div>
|
|
779
|
+
<div id="p-devices" style="display:none">
|
|
780
|
+
<div class="section-hdr"><h2>Devices <small id="dsub" style="color:var(--dim)"></small></h2></div>
|
|
781
|
+
<div class="tw"><table><thead><tr>
|
|
782
|
+
<th>Status</th>
|
|
783
|
+
<th>Device ID</th>
|
|
784
|
+
<th>Nickname</th>
|
|
785
|
+
<th>Sessions</th>
|
|
786
|
+
<th>Tokens</th>
|
|
787
|
+
<th>Cost</th>
|
|
788
|
+
<th>Last Seen</th>
|
|
789
|
+
<th>Actions</th>
|
|
790
|
+
</tr></thead><tbody id="dtb"></tbody></table></div>
|
|
791
|
+
</div>
|
|
792
|
+
<footer>OpenCode Analytics Dashboard</footer>
|
|
793
|
+
</div>
|
|
794
|
+
<script>
|
|
795
|
+
var D={},cur="models",ari=true,ri=null,aK={k:"email",d:1},sK={k:"updated",d:-1},_di=false;
|
|
796
|
+
var DCOLORS=["#58a6ff","#3fb950","#d29922","#f85149","#bc8cff","#56d4dd","#f0883e","#db61a2"];
|
|
797
|
+
function bc(p){return p>=60?"var(--green)":p>=20?"var(--yellow)":"var(--red)"}
|
|
798
|
+
function fp(f){return f==null?"--":(f*100).toFixed(0)+"%"}
|
|
799
|
+
function ft(n){return!n?"0":n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?(n/1e3).toFixed(1)+"K":""+n}
|
|
800
|
+
function fc(c){return!c?"$0.00":c<.01?"$"+c.toFixed(4):"$"+c.toFixed(2)}
|
|
801
|
+
function ta(t){if(!t)return"--";var d=Date.now()-t;return d<6e4?"now":d<36e5?Math.floor(d/6e4)+"m ago":d<864e5?Math.floor(d/36e5)+"h ago":Math.floor(d/864e5)+"d ago"}
|
|
802
|
+
function tu(t){if(!t)return"--";var d=t-Date.now();return d<=0?"now":d<36e5?Math.ceil(d/6e4)+"m":d<864e5?Math.ceil(d/36e5)+"h":Math.ceil(d/864e5)+"d"}
|
|
803
|
+
function x(s){return s?s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""):""}
|
|
804
|
+
function ds(t){if(!t)return"off";var a=Date.now()-t;return a<12e4?"on":a<6e5?"stale":"off"}
|
|
805
|
+
function sk(s){return s?s.split(".").join("~").split("$").join("~").split("/").join("~").split("[").join("~").split("]").join("~").split("#").join("~"):""}
|
|
806
|
+
function dn(id){var n=D.nicknames||{};var k=sk(id);return(n[k]||n[id]||"")}
|
|
807
|
+
function dd(id,loc){var nick=dn(id);if(nick)return x(nick)+(loc?" (this)":"");return x(id)+(loc?" (this)":"")}
|
|
808
|
+
|
|
809
|
+
function go(t){cur=t;
|
|
810
|
+
var tabs=["models","accounts","sessions","devices"];
|
|
811
|
+
document.querySelectorAll(".tab").forEach(function(el,i){el.classList.toggle("on",tabs[i]===t)});
|
|
812
|
+
tabs.forEach(function(p){
|
|
813
|
+
var el=document.getElementById("p-"+p);
|
|
814
|
+
if(el) el.style.display=p===t?"":"none";
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function rD(){
|
|
819
|
+
var h="";var devs=D.devices||[];
|
|
820
|
+
for(var i=0;i<devs.length;i++){var d=devs[i];var s=ds(d.updatedAt);var c=d.isLocal?" local":"";
|
|
821
|
+
h+='<div class="chip'+c+'"><span class="ddot '+s+'"></span>'+dd(d.device,d.isLocal)
|
|
822
|
+
+' <span style="color:var(--muted)">'+d.sessionCount+' sess · '+ta(d.updatedAt)+'</span></div>'}
|
|
823
|
+
document.getElementById("devs").innerHTML=h;
|
|
824
|
+
var sb=document.getElementById("sync");
|
|
825
|
+
if(devs.length>1){sb.className="sync-badge y";sb.textContent=devs.length+" devices synced"}
|
|
826
|
+
else{sb.className="sync-badge n";sb.textContent=D.firebaseConnected?"syncing":"local only"}
|
|
827
|
+
var sels=["df","gd","md"];
|
|
828
|
+
for(var si=0;si<sels.length;si++){var selId=sels[si];var sel=document.getElementById(selId);if(!sel)continue;var pv=sel.value;
|
|
829
|
+
sel.innerHTML='<option value="all">All devices</option>';
|
|
830
|
+
for(var i=0;i<devs.length;i++){var d=devs[i];sel.innerHTML+='<option value="'+x(d.device)+'">'+dd(d.device,d.isLocal)+'</option>'}
|
|
831
|
+
if(!_di&&D.localDevice){sel.value=D.localDevice}else{sel.value=pv||"all"}}
|
|
832
|
+
_di=true;}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
function rQ(){
|
|
836
|
+
var accs=D.accounts||[];
|
|
837
|
+
var total=accs.length;
|
|
838
|
+
var qStats={};
|
|
839
|
+
var qNames=["gemini-pro","gemini-flash","claude"];
|
|
840
|
+
for(var qi=0;qi<qNames.length;qi++){qStats[qNames[qi]]={total:total,avail:total,exhausted:0,nextReset:null,modelCount:0}}
|
|
841
|
+
for(var i=0;i<accs.length;i++){
|
|
842
|
+
var a=accs[i];
|
|
843
|
+
var rl=a.rateLimits||{};
|
|
844
|
+
var limited={};
|
|
845
|
+
var rlKeys=Object.keys(rl);
|
|
846
|
+
for(var ri=0;ri<rlKeys.length;ri++){
|
|
847
|
+
var rk=rlKeys[ri];var r=rl[rk];
|
|
848
|
+
if(r.isLimited){
|
|
849
|
+
if(rk==="claude")limited["claude"]=r.resetTime;
|
|
850
|
+
else if(rk.indexOf("flash")!==-1)limited["gemini-flash"]=r.resetTime;
|
|
851
|
+
else if(rk.indexOf("gemini")!==-1)limited["gemini-pro"]=r.resetTime;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
for(var qi=0;qi<qNames.length;qi++){
|
|
855
|
+
var k=qNames[qi];
|
|
856
|
+
if(limited[k]){
|
|
857
|
+
qStats[k].avail--;
|
|
858
|
+
qStats[k].exhausted++;
|
|
859
|
+
var rt=limited[k];
|
|
860
|
+
if(rt&&(!qStats[k].nextReset||rt<qStats[k].nextReset)){
|
|
861
|
+
qStats[k].nextReset=rt;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
var qs=a.quotas||{};
|
|
866
|
+
for(var qi=0;qi<qNames.length;qi++){
|
|
867
|
+
var q=qs[qNames[qi]];
|
|
868
|
+
if(q&&q.modelCount&&!qStats[qNames[qi]].modelCount)qStats[qNames[qi]].modelCount=q.modelCount;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
var h="";
|
|
872
|
+
for(var i=0;i<qNames.length;i++){
|
|
873
|
+
var k=qNames[i];
|
|
874
|
+
var st=qStats[k];
|
|
875
|
+
if(!st)continue;
|
|
876
|
+
var pct=st.total>0?(st.avail/st.total)*100:0;
|
|
877
|
+
var c=bc(pct);
|
|
878
|
+
var sc=st.avail>0?"var(--green)":"var(--red)";
|
|
879
|
+
var ns="";
|
|
880
|
+
if(st.avail===st.total)ns="All available";
|
|
881
|
+
else if(st.nextReset)ns="Next in "+tu(st.nextReset);
|
|
882
|
+
else ns="None available";
|
|
883
|
+
h+='<div class="sum-card">';
|
|
884
|
+
h+='<h3><span class="quota-status" style="background:'+sc+'"></span>'+x(k)+'</h3>';
|
|
885
|
+
h+='<div class="q-avail">'+st.avail+' / '+st.total+' available</div>';
|
|
886
|
+
h+='<div class="qbar" style="margin-bottom:8px"><div class="qbar-f" style="width:'+Math.max(pct,1)+'%;background:'+c+'"></div></div>';
|
|
887
|
+
h+='<div class="sub"><span>'+ns+'</span><span>'+st.modelCount+' models</span></div>';
|
|
888
|
+
h+='</div>';
|
|
889
|
+
}
|
|
890
|
+
var el=document.getElementById("quotas");
|
|
891
|
+
if(el)el.innerHTML=h;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function rSummary(){
|
|
895
|
+
var gd=document.getElementById("gd").value;
|
|
896
|
+
var days=parseInt(document.getElementById("gf").value)||9999;
|
|
897
|
+
var now=Date.now();
|
|
898
|
+
var cutoff=days===9999?0:now-(days*86400000);
|
|
899
|
+
var src={};
|
|
900
|
+
if(gd==="all"){src=D.costByDay||{}}else{
|
|
901
|
+
var devs=D.devices||[];
|
|
902
|
+
for(var di=0;di<devs.length;di++){if(devs[di].device===gd&&devs[di].costByDay){src=devs[di].costByDay;break;}}
|
|
903
|
+
}
|
|
904
|
+
var tC=0,tTok=0,tIn=0,tOut=0,tReason=0,tMsg=0;
|
|
905
|
+
var dkeys=Object.keys(src);
|
|
906
|
+
for(var di=0;di<dkeys.length;di++){
|
|
907
|
+
var dk=dkeys[di];
|
|
908
|
+
var parts=dk.split("-");
|
|
909
|
+
var dTs=new Date(parseInt(parts[0]),parseInt(parts[1])-1,parseInt(parts[2])).getTime();
|
|
910
|
+
if(cutoff&&dTs<cutoff)continue;
|
|
911
|
+
tC+=src[dk].cost||0;
|
|
912
|
+
tTok+=src[dk].tokens||0;
|
|
913
|
+
tIn+=src[dk].tokens_in||0;
|
|
914
|
+
tOut+=src[dk].tokens_out||0;
|
|
915
|
+
tReason+=src[dk].tokens_reason||0;
|
|
916
|
+
tMsg+=src[dk].msgs||0;
|
|
917
|
+
}
|
|
918
|
+
var ss=D.sessions||[];
|
|
919
|
+
var sCount=0;var modelSet={};
|
|
920
|
+
for(var i=0;i<ss.length;i++){
|
|
921
|
+
var s=ss[i];
|
|
922
|
+
if(gd!=="all"&&(s.onDevices||[]).indexOf(gd)===-1&&s.device!==gd)continue;
|
|
923
|
+
if(cutoff&&s.updated&&s.updated<cutoff)continue;
|
|
924
|
+
sCount++;
|
|
925
|
+
var mu=s.modelUsage||{};
|
|
926
|
+
for(var mid in mu){if(mu[mid].count>0)modelSet[mid]=true;}
|
|
927
|
+
}
|
|
928
|
+
var mc=Object.keys(modelSet).length;
|
|
929
|
+
var h='<div class="sum-card c1"><h3>Total Cost</h3><div class="val cost">'+fc(tC)+'</div><div class="sub"><span>'+tMsg+' messages</span><span>'+mc+' models</span></div></div>';
|
|
930
|
+
var tokSub='<span>'+tMsg+' messages</span>';
|
|
931
|
+
if(tIn>0||tOut>0){tokSub='<span>In: '+ft(tIn)+'</span><span>Out: '+ft(tOut)+'</span>'+(tReason?'<span>Think: '+ft(tReason)+'</span>':'')+'<span>'+tMsg+' msgs</span>';}
|
|
932
|
+
h+='<div class="sum-card c2"><h3>Total Tokens</h3><div class="val">'+ft(tTok)+'</div><div class="sub">'+tokSub+'</div></div>';
|
|
933
|
+
h+='<div class="sum-card c3"><h3>Total Sessions</h3><div class="val">'+sCount+'</div><div class="sub"><span>'+mc+' models used</span></div></div>';
|
|
934
|
+
document.getElementById("summary").innerHTML=h;
|
|
935
|
+
document.getElementById("info").textContent=mc+" models, "+fc(tC)+" total, "+ft(tTok)+" tokens"}
|
|
936
|
+
|
|
937
|
+
function rM(){
|
|
938
|
+
var mdVal=document.getElementById("md").value;
|
|
939
|
+
var m;
|
|
940
|
+
if(mdVal==="all"){
|
|
941
|
+
m=D.models||{};
|
|
942
|
+
}else{
|
|
943
|
+
m={};
|
|
944
|
+
var ss=D.sessions||[];
|
|
945
|
+
for(var i=0;i<ss.length;i++){
|
|
946
|
+
var s=ss[i];
|
|
947
|
+
if((s.onDevices||[]).indexOf(mdVal)===-1&&s.device!==mdVal)continue;
|
|
948
|
+
var mu=s.modelUsage||{};
|
|
949
|
+
var muKeys=Object.keys(mu);
|
|
950
|
+
for(var j=0;j<muKeys.length;j++){
|
|
951
|
+
var mid=muKeys[j];var u=mu[mid];
|
|
952
|
+
if(!m[mid])m[mid]={cost:0,tokens:{input:0,output:0,reasoning:0},provider:u.provider||"",sessionCount:0,msgCount:0};
|
|
953
|
+
m[mid].cost+=u.cost||0;
|
|
954
|
+
m[mid].tokens.input+=(u.tokens&&u.tokens.input)||0;
|
|
955
|
+
m[mid].tokens.output+=(u.tokens&&u.tokens.output)||0;
|
|
956
|
+
m[mid].tokens.reasoning+=(u.tokens&&u.tokens.reasoning)||0;
|
|
957
|
+
m[mid].sessionCount+=1;
|
|
958
|
+
m[mid].msgCount+=u.count||0;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
var keys=Object.keys(m).filter(function(k){return m[k].msgCount>0});
|
|
963
|
+
var ms=document.getElementById("mf").value;
|
|
964
|
+
keys.sort(function(a,b){
|
|
965
|
+
if(ms==="tokens"){var ta=(m[a].tokens.input||0)+(m[a].tokens.output||0)+(m[a].tokens.reasoning||0),tb=(m[b].tokens.input||0)+(m[b].tokens.output||0)+(m[b].tokens.reasoning||0);return tb-ta||m[b].cost-m[a].cost}
|
|
966
|
+
if(ms==="msgs")return(m[b].msgCount-m[a].msgCount)||(m[b].cost-m[a].cost);
|
|
967
|
+
if(ms==="name")return a<b?-1:a>b?1:0;
|
|
968
|
+
return(m[b].cost-m[a].cost)||(m[b].msgCount-m[a].msgCount)});
|
|
969
|
+
var tC=0,tI=0,tO=0,tR=0,tMsg=0;
|
|
970
|
+
for(var i=0;i<keys.length;i++){var u=m[keys[i]];tC+=u.cost;tI+=u.tokens.input;tO+=u.tokens.output;tR+=u.tokens.reasoning;tMsg+=u.msgCount}
|
|
971
|
+
var totalTok=tI+tO+tR;
|
|
972
|
+
var h="";
|
|
973
|
+
for(var i=0;i<keys.length;i++){var k=keys[i],u=m[k];
|
|
974
|
+
var pct=0;
|
|
975
|
+
if(ms==="tokens")pct=totalTok>0?(((u.tokens.input||0)+(u.tokens.output||0)+(u.tokens.reasoning||0))/totalTok*100):0;
|
|
976
|
+
else if(ms==="msgs")pct=tMsg>0?((u.msgCount/tMsg)*100):0;
|
|
977
|
+
else pct=tC>0?((u.cost/tC)*100):0;
|
|
978
|
+
var uTok=(u.tokens.input||0)+(u.tokens.output||0)+(u.tokens.reasoning||0);
|
|
979
|
+
var bigVal,bigCls,subLine;
|
|
980
|
+
if(ms==="tokens"){bigVal=ft(uTok);bigCls="";subLine='<span>'+fc(u.cost)+'</span><span>'+u.msgCount+' msgs</span><span>'+u.sessionCount+' sess</span>';}
|
|
981
|
+
else if(ms==="msgs"){bigVal=u.msgCount+' msgs';bigCls="";subLine='<span>'+fc(u.cost)+'</span><span>'+ft(uTok)+' tokens</span><span>'+u.sessionCount+' sess</span>';}
|
|
982
|
+
else{bigVal=fc(u.cost);bigCls=" cost";subLine='<span>'+ft(uTok)+' tokens</span><span>'+u.msgCount+' msgs</span><span>'+u.sessionCount+' sess</span>';}
|
|
983
|
+
h+='<div class="card"><h3>'+x(k)+'</h3><div class="val'+bigCls+'">'+bigVal+'</div>';
|
|
984
|
+
h+='<div class="bar-t"><div class="bar-f" style="width:'+Math.max(pct,1)+'%;background:var(--blue)"></div></div>';
|
|
985
|
+
h+='<div class="sub">'+subLine+'<span>'+x(u.provider)+'</span></div>';
|
|
986
|
+
h+='<div class="sub" style="margin-top:4px"><span>In: '+ft(u.tokens.input)+'</span><span>Out: '+ft(u.tokens.output)+'</span>'+(u.tokens.reasoning?'<span>Think: '+ft(u.tokens.reasoning)+'</span>':'')+'</div></div>'}
|
|
987
|
+
document.getElementById("mcards").innerHTML=h||'<div class="empty">No model usage found</div>';
|
|
988
|
+
document.getElementById("msub").textContent="("+keys.length+" models)"}
|
|
989
|
+
|
|
990
|
+
function minQuota(a){
|
|
991
|
+
var qs=a.quotas||{},keys=Object.keys(qs),min=null;
|
|
992
|
+
for(var i=0;i<keys.length;i++){var r=qs[keys[i]].remaining;if(min===null||r<min)min=r}
|
|
993
|
+
return min}
|
|
994
|
+
|
|
995
|
+
function creditsCell(a){
|
|
996
|
+
var qs=a.quotas||{},keys=Object.keys(qs);
|
|
997
|
+
if(!keys.length)return'<td><span style="color:var(--muted)">--</span></td>';
|
|
998
|
+
var parts=[];
|
|
999
|
+
for(var i=0;i<keys.length;i++){var k=keys[i],q=qs[k],p=(q.remaining*100).toFixed(0),c=bc(q.remaining*100);
|
|
1000
|
+
parts.push('<div style="display:flex;align-items:center;gap:5px;margin-bottom:2px"><span style="font-size:10px;color:var(--dim);min-width:72px">'+x(k)+'</span><div class="qbar"><div class="qbar-f" style="width:'+p+'%;background:'+c+'"></div></div><span class="qpct" style="color:'+c+'">'+p+'%</span></div>')}
|
|
1001
|
+
return'<td style="min-width:200px">'+parts.join("")+'</td>'}
|
|
1002
|
+
|
|
1003
|
+
function isRL(a){var now=Date.now();var rl=a.rateLimits||{};
|
|
1004
|
+
for(var k in rl){if(rl[k].isLimited&&rl[k].resetTime>now)return true}return false}
|
|
1005
|
+
|
|
1006
|
+
function rlInfo(a){var now=Date.now(),parts=[];var rl=a.rateLimits||{};
|
|
1007
|
+
for(var k in rl){var r=rl[k];if(r.isLimited&&r.resetTime>now){var nm=k.replace(/^.*:/,"");parts.push(x(nm)+" ("+tu(r.resetTime)+")")}}
|
|
1008
|
+
if(!parts.length)return'<td><span class="lim-n">No</span></td>';
|
|
1009
|
+
return'<td><span class="lim-y">Yes</span><br><span style="font-size:10px;color:var(--dim)">'+parts.join(", ")+'</span></td>'}
|
|
1010
|
+
|
|
1011
|
+
function rA(){
|
|
1012
|
+
var accs=D.accounts||[],f=(document.getElementById("af").value||"").toLowerCase();
|
|
1013
|
+
if(f)accs=accs.filter(function(a){return a.email.toLowerCase().indexOf(f)!==-1});
|
|
1014
|
+
var sk=aK.k,sd=aK.d;
|
|
1015
|
+
accs=accs.slice().sort(function(a,b){
|
|
1016
|
+
if(sk==="email")return a.email<b.email?-sd:a.email>b.email?sd:0;
|
|
1017
|
+
if(sk==="enabled")return((a.enabled?1:0)-(b.enabled?1:0))*sd;
|
|
1018
|
+
if(sk==="lastUsed")return((a.lastUsed||0)-(b.lastUsed||0))*sd;
|
|
1019
|
+
if(sk==="rateLimited"){return((isRL(a)?1:0)-(isRL(b)?1:0))*sd}
|
|
1020
|
+
if(sk==="credits"){var av=minQuota(a),bv=minQuota(b);return((av===null?-2:av)-(bv===null?-2:bv))*sd}
|
|
1021
|
+
return 0});
|
|
1022
|
+
var h="";
|
|
1023
|
+
for(var i=0;i<accs.length;i++){var a=accs[i];
|
|
1024
|
+
h+='<tr class="'+(a.enabled?"":"dis")+'">';
|
|
1025
|
+
var provTag=a.provider&&a.provider!=="antigravity"?'<span class="badge prov">'+x(a.provider)+'</span>':"";
|
|
1026
|
+
h+='<td>'+x(a.email)+provTag+'</td>';
|
|
1027
|
+
h+='<td>'+(a.enabled?'<span style="color:var(--green)">Active</span>':'<span style="color:var(--muted)">Disabled</span>')+'</td>';
|
|
1028
|
+
h+=creditsCell(a);
|
|
1029
|
+
h+=rlInfo(a);
|
|
1030
|
+
h+='<td style="color:var(--dim)">'+ta(a.lastUsed)+'</td>';
|
|
1031
|
+
h+='<td><button class="btn-sm '+(a.enabled?'danger':'success')+'" onclick="toggleAccount(\\''+x(a.email)+'\\', \\''+x(a.provider)+'\\')">'+(a.enabled?'Disable':'Enable')+'</button></td></tr>'}
|
|
1032
|
+
document.getElementById("atb").innerHTML=h||'<tr><td colspan="6" class="empty">No accounts</td></tr>';
|
|
1033
|
+
document.getElementById("asub").textContent="("+accs.length+" accounts)"}
|
|
1034
|
+
|
|
1035
|
+
async function toggleAccount(email, provider) {
|
|
1036
|
+
try {
|
|
1037
|
+
var res = await fetch("/api/account/toggle", {
|
|
1038
|
+
method: "POST",
|
|
1039
|
+
headers: { "Content-Type": "application/json" },
|
|
1040
|
+
body: JSON.stringify({ email: email, provider: provider })
|
|
1041
|
+
});
|
|
1042
|
+
if (res.ok) load();
|
|
1043
|
+
} catch (e) {}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async function toggleAllAccounts(enabled) {
|
|
1047
|
+
try {
|
|
1048
|
+
var res = await fetch("/api/account/toggle-all", {
|
|
1049
|
+
method: "POST",
|
|
1050
|
+
headers: { "Content-Type": "application/json" },
|
|
1051
|
+
body: JSON.stringify({ enabled: enabled })
|
|
1052
|
+
});
|
|
1053
|
+
if (res.ok) load();
|
|
1054
|
+
} catch (e) {}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function rS(){
|
|
1058
|
+
var ss=D.sessions||[],df=document.getElementById("df").value;
|
|
1059
|
+
if(df!=="all")ss=ss.filter(function(s){return(s.onDevices||[]).indexOf(df)!==-1||s.device===df});
|
|
1060
|
+
var sk=sK.k,sd=sK.d;
|
|
1061
|
+
ss=ss.slice().sort(function(a,b){
|
|
1062
|
+
if(sk==="title")return(a.title||"")<(b.title||"")?-sd:(a.title||"")>(b.title||"")?sd:0;
|
|
1063
|
+
if(sk==="device")return(a.device||"")<(b.device||"")?-sd:(a.device||"")>(b.device||"")?sd:0;
|
|
1064
|
+
if(sk==="cost")return((a.cost||0)-(b.cost||0))*sd;
|
|
1065
|
+
if(sk==="tokens")return(((a.tokens?.input||0)+(a.tokens?.output||0))-((b.tokens?.input||0)+(b.tokens?.output||0)))*sd;
|
|
1066
|
+
return((a.updated||0)-(b.updated||0))*sd});
|
|
1067
|
+
var tC=0,tI=0,tO=0,tR2=0;
|
|
1068
|
+
for(var i=0;i<ss.length;i++){var z=ss[i];tC+=z.cost||0;tI+=(z.tokens&&z.tokens.input)||0;tO+=(z.tokens&&z.tokens.output)||0;tR2+=(z.tokens&&z.tokens.reasoning)||0}
|
|
1069
|
+
var h="";
|
|
1070
|
+
for(var i=0;i<ss.length;i++){var s=ss[i];
|
|
1071
|
+
var tt=(s.tokens?.input||0)+(s.tokens?.output||0)+(s.tokens?.reasoning||0);
|
|
1072
|
+
var mods=Object.keys(s.modelUsage||{}).filter(function(k){return(s.modelUsage[k].count||0)>0});
|
|
1073
|
+
h+='<tr><td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+x(s.title);
|
|
1074
|
+
if(mods.length)h+='<br>'+mods.map(function(m){return'<span class="badge">'+x(m)+'</span>'}).join(" ");
|
|
1075
|
+
h+='</td>';
|
|
1076
|
+
h+='<td>';var devs=s.onDevices||[s.device||D.localDevice||""];for(var di=0;di<devs.length;di++){var isLoc=devs[di]===D.localDevice;h+='<span class="badge'+(isLoc?" local":"")+'">'+dd(devs[di],false)+(isLoc?" ✦":"")+'</span> '}h+='</td>';
|
|
1077
|
+
h+='<td class="cost">'+fc(s.cost)+'</td><td>';
|
|
1078
|
+
if(tt>0){h+='<span class="pill">In:'+ft(s.tokens.input)+'</span><span class="pill">Out:'+ft(s.tokens.output)+'</span>';
|
|
1079
|
+
if(s.tokens.reasoning>0)h+='<span class="pill">Think:'+ft(s.tokens.reasoning)+'</span>'}
|
|
1080
|
+
else h+='<span style="color:var(--muted)">--</span>';
|
|
1081
|
+
h+='</td><td style="color:var(--dim)">'+ta(s.updated)+'</td></tr>'}
|
|
1082
|
+
if(ss.length){
|
|
1083
|
+
h+='<tr class="totals"><td>Total ('+ss.length+')</td><td></td>';
|
|
1084
|
+
h+='<td class="cost">'+fc(tC)+'</td><td>';
|
|
1085
|
+
h+='<span class="pill">In:'+ft(tI)+'</span><span class="pill">Out:'+ft(tO)+'</span>';
|
|
1086
|
+
if(tR2)h+='<span class="pill">Think:'+ft(tR2)+'</span>';
|
|
1087
|
+
h+='</td><td></td></tr>'}
|
|
1088
|
+
document.getElementById("stb").innerHTML=h||'<tr><td colspan="5" class="empty">No sessions</td></tr>';
|
|
1089
|
+
document.getElementById("ssub").textContent="("+ss.length+" sessions)"}
|
|
1090
|
+
|
|
1091
|
+
function sA(k){if(aK.k===k)aK.d*=-1;else{aK.k=k;aK.d=k==="email"?1:-1}rA()}
|
|
1092
|
+
function sS(k){if(sK.k===k)sK.d*=-1;else{sK.k=k;sK.d=k==="title"||k==="device"?1:-1}rS()}
|
|
1093
|
+
|
|
1094
|
+
function toggleAR(){ari=!ari;document.getElementById("ar").classList.toggle("on",ari);
|
|
1095
|
+
var d=document.getElementById("dot");d.style.background=ari?"var(--green)":"var(--muted)";d.style.animation=ari?"pulse 2s infinite":"none";
|
|
1096
|
+
ari?startAR():stopAR()}
|
|
1097
|
+
function startAR(){stopAR();ri=setInterval(load,30000)}
|
|
1098
|
+
function stopAR(){if(ri){clearInterval(ri);ri=null}}
|
|
1099
|
+
|
|
1100
|
+
function rG(){
|
|
1101
|
+
var gd=document.getElementById("gd").value;
|
|
1102
|
+
var days=parseInt(document.getElementById("gf").value)||9999;
|
|
1103
|
+
var metric=document.getElementById("gm").value;
|
|
1104
|
+
var now=Date.now();
|
|
1105
|
+
var cutoff=days===9999?0:now-(days*86400000);
|
|
1106
|
+
var container=document.getElementById("graph-container");
|
|
1107
|
+
var tip=document.getElementById("graph-tip");
|
|
1108
|
+
var legendEl=document.getElementById("graph-legend");
|
|
1109
|
+
|
|
1110
|
+
var allDatesMap={};
|
|
1111
|
+
var aggSrc=D.costByDay||{};
|
|
1112
|
+
var aggKeys=Object.keys(aggSrc);
|
|
1113
|
+
for(var i=0;i<aggKeys.length;i++){
|
|
1114
|
+
var dk=aggKeys[i];var pts=dk.split("-");
|
|
1115
|
+
var dTs=new Date(parseInt(pts[0]),parseInt(pts[1])-1,parseInt(pts[2])).getTime();
|
|
1116
|
+
if(cutoff&&dTs<cutoff)continue;
|
|
1117
|
+
allDatesMap[dk]=true;
|
|
1118
|
+
}
|
|
1119
|
+
var devs=D.devices||[];
|
|
1120
|
+
for(var di=0;di<devs.length;di++){
|
|
1121
|
+
var dcbd=devs[di].costByDay||{};
|
|
1122
|
+
var dcKeys=Object.keys(dcbd);
|
|
1123
|
+
for(var ci=0;ci<dcKeys.length;ci++){
|
|
1124
|
+
var dk=dcKeys[ci];var pts=dk.split("-");
|
|
1125
|
+
var dTs=new Date(parseInt(pts[0]),parseInt(pts[1])-1,parseInt(pts[2])).getTime();
|
|
1126
|
+
if(cutoff&&dTs<cutoff)continue;
|
|
1127
|
+
allDatesMap[dk]=true;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
var sortedDates=Object.keys(allDatesMap).sort();
|
|
1131
|
+
|
|
1132
|
+
if(!sortedDates.length){
|
|
1133
|
+
container.innerHTML='<div class="empty-graph">No data for this period</div>';
|
|
1134
|
+
if(legendEl)legendEl.innerHTML="";
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
var series=[];
|
|
1139
|
+
if(gd==="all"){
|
|
1140
|
+
var aggData=[];
|
|
1141
|
+
for(var i=0;i<sortedDates.length;i++){
|
|
1142
|
+
var dk=sortedDates[i];var e=aggSrc[dk]||{};
|
|
1143
|
+
aggData.push({d:dk,v:e[metric]||0});
|
|
1144
|
+
}
|
|
1145
|
+
series.push({name:"All Devices",data:aggData,color:"#e6edf3",thick:true});
|
|
1146
|
+
for(var di=0;di<devs.length;di++){
|
|
1147
|
+
var dev=devs[di];var dcbd=dev.costByDay||{};
|
|
1148
|
+
var devData=[];var hasData=false;
|
|
1149
|
+
for(var i=0;i<sortedDates.length;i++){
|
|
1150
|
+
var dk=sortedDates[i];var e=dcbd[dk]||{};var val=e[metric]||0;
|
|
1151
|
+
if(val>0)hasData=true;
|
|
1152
|
+
devData.push({d:dk,v:val});
|
|
1153
|
+
}
|
|
1154
|
+
if(hasData){
|
|
1155
|
+
var rawName=dn(dev.device)||dev.device;
|
|
1156
|
+
var dName=rawName+(dev.isLocal?" (this)":"");
|
|
1157
|
+
series.push({name:dName,data:devData,color:DCOLORS[di%DCOLORS.length],thick:false});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}else{
|
|
1161
|
+
var devCbd={};
|
|
1162
|
+
for(var di=0;di<devs.length;di++){
|
|
1163
|
+
if(devs[di].device===gd){devCbd=devs[di].costByDay||{};break;}
|
|
1164
|
+
}
|
|
1165
|
+
var devData=[];
|
|
1166
|
+
for(var i=0;i<sortedDates.length;i++){
|
|
1167
|
+
var dk=sortedDates[i];var e=devCbd[dk]||{};
|
|
1168
|
+
devData.push({d:dk,v:e[metric]||0});
|
|
1169
|
+
}
|
|
1170
|
+
var rawName=dn(gd)||gd;
|
|
1171
|
+
series.push({name:rawName,data:devData,color:DCOLORS[0],thick:true});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
var max=0;
|
|
1175
|
+
for(var si=0;si<series.length;si++){
|
|
1176
|
+
for(var i=0;i<series[si].data.length;i++){
|
|
1177
|
+
if(series[si].data[i].v>max)max=series[si].data[i].v;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if(max===0)max=1;
|
|
1181
|
+
|
|
1182
|
+
var w=container.offsetWidth||800;
|
|
1183
|
+
var h=240;
|
|
1184
|
+
var padL=58,padR=16,padT=16,padB=28;
|
|
1185
|
+
var plotW=w-padL-padR;
|
|
1186
|
+
var plotH=h-padT-padB;
|
|
1187
|
+
|
|
1188
|
+
var svg='<svg width="'+w+'" height="'+h+'" xmlns="http://www.w3.org/2000/svg" style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:block">';
|
|
1189
|
+
|
|
1190
|
+
var ySteps=[0,0.25,0.5,0.75,1.0];
|
|
1191
|
+
for(var yi=0;yi<ySteps.length;yi++){
|
|
1192
|
+
var yPx=padT+plotH-(ySteps[yi]*plotH);
|
|
1193
|
+
var yVal=max*ySteps[yi];
|
|
1194
|
+
var yLabel=metric==="tokens"?ft(yVal):metric==="cost"?fc(yVal):Math.round(yVal);
|
|
1195
|
+
svg+='<line x1="'+padL+'" y1="'+yPx+'" x2="'+(w-padR)+'" y2="'+yPx+'" stroke="#30363d" stroke-dasharray="'+(ySteps[yi]===0?"0":"3,3")+'"/>';
|
|
1196
|
+
svg+='<text x="'+(padL-10)+'" y="'+(yPx+4)+'" text-anchor="end" fill="#8b949e" font-size="10">'+yLabel+'</text>';
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
var maxLabels=12;
|
|
1200
|
+
var labelStep=Math.max(1,Math.ceil(sortedDates.length/maxLabels));
|
|
1201
|
+
for(var i=0;i<sortedDates.length;i+=labelStep){
|
|
1202
|
+
var xPx=padL+(sortedDates.length>1?(i/(sortedDates.length-1))*plotW:plotW/2);
|
|
1203
|
+
var shortDate=sortedDates[i].substring(5);
|
|
1204
|
+
svg+='<text x="'+xPx+'" y="'+(h-4)+'" text-anchor="middle" fill="#8b949e" font-size="10">'+shortDate+'</text>';
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
var drawOrder=[];
|
|
1208
|
+
for(var si=0;si<series.length;si++){drawOrder.push(series[si])}
|
|
1209
|
+
drawOrder.sort(function(a,b){return(a.thick?1:0)-(b.thick?1:0)});
|
|
1210
|
+
|
|
1211
|
+
for(var si=0;si<drawOrder.length;si++){
|
|
1212
|
+
var s=drawOrder[si];
|
|
1213
|
+
var points=[];
|
|
1214
|
+
for(var i=0;i<s.data.length;i++){
|
|
1215
|
+
var xPx=padL+(s.data.length>1?(i/(s.data.length-1))*plotW:plotW/2);
|
|
1216
|
+
var yPx=padT+plotH-((s.data[i].v/max)*plotH);
|
|
1217
|
+
points.push({x:xPx,y:yPx});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if(s.thick&&points.length>1){
|
|
1221
|
+
var polyPts="";
|
|
1222
|
+
for(var pi=0;pi<points.length;pi++){polyPts+=(pi>0?" ":"")+points[pi].x.toFixed(1)+","+points[pi].y.toFixed(1)}
|
|
1223
|
+
var bY=padT+plotH;
|
|
1224
|
+
svg+='<polygon points="'+points[0].x.toFixed(1)+','+bY+' '+polyPts+' '+points[points.length-1].x.toFixed(1)+','+bY+'" fill="'+s.color+'" fill-opacity="0.05"/>';
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if(points.length>1){
|
|
1228
|
+
var linePts="";
|
|
1229
|
+
for(var pi=0;pi<points.length;pi++){linePts+=(pi>0?" ":"")+points[pi].x.toFixed(1)+","+points[pi].y.toFixed(1)}
|
|
1230
|
+
var sw=s.thick?"2.5":"1.5";
|
|
1231
|
+
var op=s.thick?"0.9":"0.5";
|
|
1232
|
+
svg+='<polyline points="'+linePts+'" fill="none" stroke="'+s.color+'" stroke-width="'+sw+'" stroke-opacity="'+op+'" stroke-linejoin="round" stroke-linecap="round"/>';
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if(s.data.length<=90){
|
|
1236
|
+
for(var i=0;i<s.data.length;i++){
|
|
1237
|
+
var px=points[i].x.toFixed(1);
|
|
1238
|
+
var py=points[i].y.toFixed(1);
|
|
1239
|
+
var r=s.thick?"2.5":"1.5";
|
|
1240
|
+
var dop=s.thick?"0.9":"0.5";
|
|
1241
|
+
svg+='<circle cx="'+px+'" cy="'+py+'" r="'+r+'" fill="'+s.color+'" fill-opacity="'+dop+'" style="pointer-events:none" data-si="'+si+'" data-di="'+i+'"/>';
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
svg+='<line id="gcross" x1="0" y1="'+padT+'" x2="0" y2="'+(padT+plotH)+'" stroke="#e6edf3" stroke-opacity="0.2" stroke-width="1" style="display:none;pointer-events:none"/>';
|
|
1247
|
+
svg+='<rect x="'+padL+'" y="'+padT+'" width="'+plotW+'" height="'+plotH+'" fill="transparent" id="ghover" style="cursor:crosshair"/>';
|
|
1248
|
+
svg+='</svg>';
|
|
1249
|
+
container.innerHTML=svg;
|
|
1250
|
+
|
|
1251
|
+
var _gXs=[];
|
|
1252
|
+
if(sortedDates.length>0){
|
|
1253
|
+
for(var gi=0;gi<sortedDates.length;gi++){
|
|
1254
|
+
_gXs.push(padL+(sortedDates.length>1?(gi/(sortedDates.length-1))*plotW:plotW/2));
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
window._gSeries=series;
|
|
1258
|
+
window._gDates=sortedDates;
|
|
1259
|
+
window._gXs=_gXs;
|
|
1260
|
+
window._gMetric=metric;
|
|
1261
|
+
window._gPadT=padT;
|
|
1262
|
+
window._gPlotH=plotH;
|
|
1263
|
+
|
|
1264
|
+
var hoverRect=document.getElementById("ghover");
|
|
1265
|
+
var crossLine=document.getElementById("gcross");
|
|
1266
|
+
|
|
1267
|
+
if(hoverRect){
|
|
1268
|
+
hoverRect.onmousemove=function(e){
|
|
1269
|
+
var rect=container.getBoundingClientRect();
|
|
1270
|
+
var mx=e.clientX-rect.left;
|
|
1271
|
+
var bestIdx=0;var bestDist=9999;
|
|
1272
|
+
for(var gi=0;gi<_gXs.length;gi++){
|
|
1273
|
+
var dist=Math.abs(_gXs[gi]-mx);
|
|
1274
|
+
if(dist<bestDist){bestDist=dist;bestIdx=gi;}
|
|
1275
|
+
}
|
|
1276
|
+
if(crossLine){crossLine.setAttribute("x1",_gXs[bestIdx]);crossLine.setAttribute("x2",_gXs[bestIdx]);crossLine.style.display="";}
|
|
1277
|
+
var allCircles=container.querySelectorAll("circle[data-si]");
|
|
1278
|
+
for(var ci=0;ci<allCircles.length;ci++){
|
|
1279
|
+
var cdi=parseInt(allCircles[ci].getAttribute("data-di"));
|
|
1280
|
+
if(cdi===bestIdx){allCircles[ci].setAttribute("r","5");}
|
|
1281
|
+
else{var csi=parseInt(allCircles[ci].getAttribute("data-si"));var origR=(window._gSeries[csi]&&window._gSeries[csi].thick)?"2.5":"1.5";allCircles[ci].setAttribute("r",origR);}
|
|
1282
|
+
}
|
|
1283
|
+
if(tip){
|
|
1284
|
+
var date=window._gDates[bestIdx]||"";
|
|
1285
|
+
var lines='<div style="font-weight:600;margin-bottom:4px;color:var(--text)">'+date+'</div>';
|
|
1286
|
+
for(var si=0;si<window._gSeries.length;si++){
|
|
1287
|
+
var sv=window._gSeries[si];
|
|
1288
|
+
var val=sv.data[bestIdx]?sv.data[bestIdx].v:0;
|
|
1289
|
+
var fmtVal=window._gMetric==="tokens"?ft(val):window._gMetric==="cost"?fc(val):val;
|
|
1290
|
+
var weight=sv.thick?"600":"400";
|
|
1291
|
+
var op=sv.thick?"1":"0.8";
|
|
1292
|
+
lines+='<div style="display:flex;align-items:center;gap:6px;padding:1px 0;font-weight:'+weight+';opacity:'+op+'"><span style="width:8px;height:8px;border-radius:2px;background:'+sv.color+';display:inline-block;flex-shrink:0"></span><span style="flex:1">'+x(sv.name)+'</span><span style="font-variant-numeric:tabular-nums;margin-left:12px">'+fmtVal+'</span></div>';
|
|
1293
|
+
}
|
|
1294
|
+
tip.innerHTML=lines;
|
|
1295
|
+
tip.style.display="block";
|
|
1296
|
+
var tx=_gXs[bestIdx]-rect.left+rect.left-container.getBoundingClientRect().left+14;
|
|
1297
|
+
var ty=e.clientY-container.getBoundingClientRect().top-20;
|
|
1298
|
+
var tipW=tip.offsetWidth||180;
|
|
1299
|
+
if(tx+tipW>container.offsetWidth)tx=_gXs[bestIdx]-container.getBoundingClientRect().left-tipW-14;
|
|
1300
|
+
if(ty<0)ty=10;
|
|
1301
|
+
tip.style.left=tx+"px";
|
|
1302
|
+
tip.style.top=ty+"px";
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
hoverRect.onmouseleave=function(){
|
|
1306
|
+
if(crossLine)crossLine.style.display="none";
|
|
1307
|
+
if(tip)tip.style.display="none";
|
|
1308
|
+
var allCircles=container.querySelectorAll("circle[data-si]");
|
|
1309
|
+
for(var ci=0;ci<allCircles.length;ci++){var csi=parseInt(allCircles[ci].getAttribute("data-si"));var origR=(window._gSeries[csi]&&window._gSeries[csi].thick)?"2.5":"1.5";allCircles[ci].setAttribute("r",origR);}
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
container.onmouseleave=function(){if(tip)tip.style.display="none";if(crossLine)crossLine.style.display="none"};
|
|
1313
|
+
|
|
1314
|
+
if(legendEl){
|
|
1315
|
+
if(series.length>1){
|
|
1316
|
+
var lh="";
|
|
1317
|
+
for(var si=0;si<series.length;si++){
|
|
1318
|
+
var s=series[si];
|
|
1319
|
+
var lop=s.thick?"1":"0.7";
|
|
1320
|
+
lh+='<span class="legend-item" style="opacity:'+lop+'"><span class="legend-dot" style="background:'+s.color+'"></span>'+x(s.name)+'</span>';
|
|
1321
|
+
}
|
|
1322
|
+
legendEl.innerHTML=lh;
|
|
1323
|
+
}else{
|
|
1324
|
+
legendEl.innerHTML="";
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function rDevTab(){
|
|
1330
|
+
var devs=D.devices||[];var nicks=D.nicknames||{};var h="";
|
|
1331
|
+
window._devIds=[];
|
|
1332
|
+
for(var i=0;i<devs.length;i++){var d=devs[i];var s=ds(d.updatedAt);
|
|
1333
|
+
window._devIds.push(d.device);
|
|
1334
|
+
var safeId=sk(d.device);
|
|
1335
|
+
var nick=nicks[safeId]||nicks[d.device]||"";
|
|
1336
|
+
h+='<tr><td><span class="ddot '+s+'" style="margin-right:6px"></span>'+(s==="on"?'<span style="color:var(--green)">Online</span>':s==="stale"?'<span style="color:var(--yellow)">Stale</span>':'<span style="color:var(--muted)">Offline</span>')+'</td>';
|
|
1337
|
+
h+='<td style="font-family:monospace;font-size:12px">'+x(d.device)+(d.isLocal?' <span class="badge local">this</span>':'')+'</td>';
|
|
1338
|
+
h+='<td id="nick-cell-'+i+'">';
|
|
1339
|
+
if(nick){h+='<span class="dev-nick" onclick="editNick('+i+')">'+x(nick)+'</span>';}
|
|
1340
|
+
else{h+='<button class="btn-sm" onclick="editNick('+i+')">Set nickname</button>';}
|
|
1341
|
+
h+='</td>';
|
|
1342
|
+
h+='<td>'+d.sessionCount+'</td>';
|
|
1343
|
+
h+='<td>'+ft(d.totalTokens||0)+'</td>';
|
|
1344
|
+
h+='<td class="cost">'+fc(d.totalCost||0)+'</td>';
|
|
1345
|
+
h+='<td style="color:var(--dim)">'+ta(d.updatedAt)+'</td>';
|
|
1346
|
+
h+='<td>';
|
|
1347
|
+
if(!d.isLocal){h+='<button class="btn-sm danger" onclick="removeDev('+i+')">Remove</button>';}
|
|
1348
|
+
else{h+='<span style="color:var(--muted);font-size:11px">--</span>';}
|
|
1349
|
+
h+='</td></tr>';}
|
|
1350
|
+
document.getElementById("dtb").innerHTML=h||'<tr><td colspan="8" class="empty">No devices</td></tr>';
|
|
1351
|
+
document.getElementById("dsub").textContent="("+devs.length+" devices)";
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
var editingNickIdx=-1;
|
|
1355
|
+
function editNick(idx){
|
|
1356
|
+
editingNickIdx=idx;
|
|
1357
|
+
var deviceId=window._devIds[idx];if(!deviceId)return;
|
|
1358
|
+
var cell=document.getElementById("nick-cell-"+idx);if(!cell)return;
|
|
1359
|
+
var safeId=sk(deviceId);
|
|
1360
|
+
var nicks=D.nicknames||{};var cur=nicks[safeId]||nicks[deviceId]||"";
|
|
1361
|
+
cell.innerHTML='<input class="nick-input" id="nick-inp-'+idx+'" value="'+x(cur)+'" onkeydown="nickKey(event)" placeholder="Enter nickname...">'
|
|
1362
|
+
+' <button class="btn-sm" onclick="saveNick()">Save</button>'
|
|
1363
|
+
+' <button class="btn-sm" onclick="rDevTab()">Cancel</button>';
|
|
1364
|
+
var inp=document.getElementById("nick-inp-"+idx);if(inp)inp.focus();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function nickKey(e){if(e.key==="Enter"){e.preventDefault();saveNick();}if(e.key==="Escape"){rDevTab();}}
|
|
1368
|
+
|
|
1369
|
+
function saveNick(){
|
|
1370
|
+
var deviceId=window._devIds[editingNickIdx];if(!deviceId)return;
|
|
1371
|
+
var inp=document.getElementById("nick-inp-"+editingNickIdx);if(!inp)return;
|
|
1372
|
+
var val=inp.value.trim();
|
|
1373
|
+
fetch("/api/nickname",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:deviceId,nickname:val})})
|
|
1374
|
+
.then(function(r){return r.json()}).then(function(res){if(res.ok){load();}else{rDevTab();}}).catch(function(){rDevTab();});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function removeDev(idx){
|
|
1378
|
+
var deviceId=window._devIds[idx];if(!deviceId)return;
|
|
1379
|
+
if(!confirm("Remove device "+deviceId+"? This deletes its synced data from Firebase."))return;
|
|
1380
|
+
fetch("/api/device/remove",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:deviceId})})
|
|
1381
|
+
.then(function(r){return r.json()}).then(function(res){if(res.ok){load();}}).catch(function(){});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function load(){
|
|
1385
|
+
fetch("/api/data").then(function(r){return r.json()}).then(function(d){
|
|
1386
|
+
D=d;rD();rSummary();rQ();rM();rA();rS();rG();rDevTab();
|
|
1387
|
+
document.getElementById("when").textContent="Updated "+new Date().toLocaleTimeString();
|
|
1388
|
+
}).catch(function(err){})}
|
|
1389
|
+
|
|
1390
|
+
var _resizeTimer;
|
|
1391
|
+
window.onresize=function(){clearTimeout(_resizeTimer);_resizeTimer=setTimeout(function(){rG()},200)};
|
|
1392
|
+
|
|
1393
|
+
load();startAR();
|
|
1394
|
+
</script>
|
|
1395
|
+
</body>
|
|
1396
|
+
</html>`;
|
|
1397
|
+
|
|
1398
|
+
var OC_BASH = [
|
|
1399
|
+
'#!/usr/bin/env bash',
|
|
1400
|
+
'# oc - OpenCode project launcher (auto-installed by credit-dashboard plugin)',
|
|
1401
|
+
'set -e',
|
|
1402
|
+
'if [ "$1" = "remove" ]; then',
|
|
1403
|
+
' SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
|
|
1404
|
+
' rm -f "$SCRIPT_DIR/oc" "$SCRIPT_DIR/oc-tui.js" "$SCRIPT_DIR/oc.cmd"',
|
|
1405
|
+
' echo "oc launcher removed. Will be reinstalled on next opencode start if plugin is still active."',
|
|
1406
|
+
' exit 0',
|
|
1407
|
+
'fi',
|
|
1408
|
+
'if [ $# -gt 0 ]; then exec opencode "$@"; fi',
|
|
1409
|
+
'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
|
|
1410
|
+
'export OC_OUTPUT="${TEMP:-${TMPDIR:-/tmp}}/oc-dir-$$.txt"',
|
|
1411
|
+
'bun run "$SCRIPT_DIR/oc-tui.js"',
|
|
1412
|
+
'EXIT=$?',
|
|
1413
|
+
'if [ $EXIT -eq 0 ] && [ -f "$OC_OUTPUT" ]; then',
|
|
1414
|
+
' DIR=$(cat "$OC_OUTPUT")',
|
|
1415
|
+
' rm -f "$OC_OUTPUT"',
|
|
1416
|
+
' if [ -n "$DIR" ]; then cd "$DIR" && exec opencode; fi',
|
|
1417
|
+
'fi',
|
|
1418
|
+
'rm -f "$OC_OUTPUT"',
|
|
1419
|
+
'exit $EXIT',
|
|
1420
|
+
''
|
|
1421
|
+
].join('\n');
|
|
1422
|
+
|
|
1423
|
+
var OC_CMD = [
|
|
1424
|
+
'@echo off',
|
|
1425
|
+
'if /i "%~1"=="remove" (',
|
|
1426
|
+
' del "%~dp0oc-tui.js" 2>nul',
|
|
1427
|
+
' del "%~dp0oc" 2>nul',
|
|
1428
|
+
' echo oc launcher removed. Will be reinstalled on next opencode start if plugin is still active.',
|
|
1429
|
+
' del "%~dp0oc.cmd" 2>nul & exit /b 0',
|
|
1430
|
+
')',
|
|
1431
|
+
'if not "%~1"=="" (opencode %* & exit /b)',
|
|
1432
|
+
'setlocal',
|
|
1433
|
+
'set "SCRIPT_DIR=%~dp0"',
|
|
1434
|
+
'set "OC_OUTPUT=%TEMP%\\oc-dir-%RANDOM%.txt"',
|
|
1435
|
+
'call bun run "%SCRIPT_DIR%oc-tui.js" %*',
|
|
1436
|
+
'if errorlevel 1 (',
|
|
1437
|
+
' del "%OC_OUTPUT%" 2>nul',
|
|
1438
|
+
' exit /b 1',
|
|
1439
|
+
')',
|
|
1440
|
+
'if not exist "%OC_OUTPUT%" exit /b 1',
|
|
1441
|
+
'set /p OCDIR=<"%OC_OUTPUT%"',
|
|
1442
|
+
'del "%OC_OUTPUT%" 2>nul',
|
|
1443
|
+
'if not defined OCDIR exit /b 1',
|
|
1444
|
+
'endlocal & cd /d "%OCDIR%" & opencode',
|
|
1445
|
+
''
|
|
1446
|
+
].join('\r\n');
|
|
1447
|
+
|
|
1448
|
+
var PATH_LINE = 'export PATH="$HOME/.local/bin:$PATH"';
|
|
1449
|
+
var PATH_MARKER = "# oc-launcher PATH";
|
|
1450
|
+
|
|
1451
|
+
async function installOcLauncher() {
|
|
1452
|
+
var home = homedir();
|
|
1453
|
+
var binDir = join(home, ".local", "bin");
|
|
1454
|
+
var tuiSrc = join(import.meta.dir, "..", "oc-tui.js");
|
|
1455
|
+
var tuiDst = join(binDir, "oc-tui.js");
|
|
1456
|
+
var bashPath = join(binDir, "oc");
|
|
1457
|
+
var isWin = process.platform === "win32";
|
|
1458
|
+
|
|
1459
|
+
if (!existsSync(binDir)) mkdirSync(binDir, { recursive: true });
|
|
1460
|
+
|
|
1461
|
+
if (existsSync(tuiSrc)) {
|
|
1462
|
+
writeFileSync(tuiDst, readFileSync(tuiSrc));
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
writeFileSync(bashPath, OC_BASH, { mode: 0o755 });
|
|
1466
|
+
try { chmodSync(bashPath, 0o755); } catch {}
|
|
1467
|
+
|
|
1468
|
+
if (isWin) {
|
|
1469
|
+
var cmdPath = join(binDir, "oc.cmd");
|
|
1470
|
+
writeFileSync(cmdPath, OC_CMD);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
var oldQuery = join(binDir, "oc-query.js");
|
|
1474
|
+
try { if (existsSync(oldQuery)) { var fs = require("fs"); fs.unlinkSync(oldQuery); } } catch {}
|
|
1475
|
+
|
|
1476
|
+
if (!isWin) {
|
|
1477
|
+
var rcCandidates = [
|
|
1478
|
+
join(home, ".bashrc"),
|
|
1479
|
+
join(home, ".bash_profile"),
|
|
1480
|
+
join(home, ".profile"),
|
|
1481
|
+
];
|
|
1482
|
+
var zshrc = join(home, ".zshrc");
|
|
1483
|
+
var zprofile = join(home, ".zprofile");
|
|
1484
|
+
if (process.platform === "darwin") {
|
|
1485
|
+
rcCandidates.push(zprofile);
|
|
1486
|
+
rcCandidates.push(zshrc);
|
|
1487
|
+
} else {
|
|
1488
|
+
if (existsSync(zshrc)) rcCandidates.push(zshrc);
|
|
1489
|
+
if (existsSync(zprofile)) rcCandidates.push(zprofile);
|
|
1490
|
+
}
|
|
1491
|
+
for (var rcFile of rcCandidates) {
|
|
1492
|
+
var content = "";
|
|
1493
|
+
if (existsSync(rcFile)) {
|
|
1494
|
+
content = readFileSync(rcFile, "utf-8");
|
|
1495
|
+
if (content.includes(".local/bin")) continue;
|
|
1496
|
+
} else {
|
|
1497
|
+
if (rcFile === zshrc || rcFile === zprofile) {
|
|
1498
|
+
if (process.platform !== "darwin") continue;
|
|
1499
|
+
} else {
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
var addition = "\n" + PATH_MARKER + "\n" + PATH_LINE + "\n";
|
|
1504
|
+
writeFileSync(rcFile, content + addition, "utf-8");
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if (isWin) {
|
|
1509
|
+
var winBinDir = join(home, ".local", "bin");
|
|
1510
|
+
var runtimePath = process.env.PATH || process.env.Path || "";
|
|
1511
|
+
if (!runtimePath.includes(winBinDir)) {
|
|
1512
|
+
try {
|
|
1513
|
+
var getProc = Bun.spawn(["powershell", "-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path', 'User')"], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
1514
|
+
var currentPath = (await new Response(getProc.stdout).text()).trim();
|
|
1515
|
+
await getProc.exited;
|
|
1516
|
+
if (currentPath && !currentPath.includes(winBinDir)) {
|
|
1517
|
+
var newPath = winBinDir + ";" + currentPath;
|
|
1518
|
+
var setProc = Bun.spawn(["powershell", "-NoProfile", "-Command", "[Environment]::SetEnvironmentVariable('Path', '" + newPath.replace(/'/g, "''") + "', 'User')"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
1519
|
+
await setProc.exited;
|
|
1520
|
+
}
|
|
1521
|
+
} catch {}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function uninstallOcLauncher() {
|
|
1527
|
+
var home = homedir();
|
|
1528
|
+
var binDir = join(home, ".local", "bin");
|
|
1529
|
+
var isWin = process.platform === "win32";
|
|
1530
|
+
var removed = [];
|
|
1531
|
+
|
|
1532
|
+
var files = ["oc", "oc-tui.js"];
|
|
1533
|
+
if (isWin) files.push("oc.cmd");
|
|
1534
|
+
for (var f of files) {
|
|
1535
|
+
var p = join(binDir, f);
|
|
1536
|
+
try { if (existsSync(p)) { unlinkSync(p); removed.push(p); } } catch {}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
var rcFiles = [
|
|
1540
|
+
join(home, ".bashrc"),
|
|
1541
|
+
join(home, ".bash_profile"),
|
|
1542
|
+
join(home, ".profile"),
|
|
1543
|
+
join(home, ".zshrc"),
|
|
1544
|
+
join(home, ".zprofile"),
|
|
1545
|
+
];
|
|
1546
|
+
for (var rcFile of rcFiles) {
|
|
1547
|
+
if (!existsSync(rcFile)) continue;
|
|
1548
|
+
try {
|
|
1549
|
+
var content = readFileSync(rcFile, "utf-8");
|
|
1550
|
+
if (!content.includes(PATH_MARKER)) continue;
|
|
1551
|
+
var lines = content.split("\n");
|
|
1552
|
+
var out = [];
|
|
1553
|
+
var skip = false;
|
|
1554
|
+
for (var i = 0; i < lines.length; i++) {
|
|
1555
|
+
if (lines[i].trim() === PATH_MARKER) { skip = true; continue; }
|
|
1556
|
+
if (skip && lines[i].trim() === PATH_LINE) { skip = false; continue; }
|
|
1557
|
+
skip = false;
|
|
1558
|
+
out.push(lines[i]);
|
|
1559
|
+
}
|
|
1560
|
+
var cleaned = out.join("\n").replace(/\n{3,}$/g, "\n\n");
|
|
1561
|
+
writeFileSync(rcFile, cleaned, "utf-8");
|
|
1562
|
+
removed.push(rcFile + " (PATH entry)");
|
|
1563
|
+
} catch {}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
if (isWin) {
|
|
1567
|
+
try {
|
|
1568
|
+
var winBinDir = join(home, ".local", "bin");
|
|
1569
|
+
var getProc = Bun.spawn(["powershell", "-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path', 'User')"], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
1570
|
+
var currentPath = (await new Response(getProc.stdout).text()).trim();
|
|
1571
|
+
await getProc.exited;
|
|
1572
|
+
if (currentPath && currentPath.includes(winBinDir)) {
|
|
1573
|
+
var parts = currentPath.split(";").filter(function(p) { return p !== winBinDir; });
|
|
1574
|
+
var newPath = parts.join(";");
|
|
1575
|
+
var setProc = Bun.spawn(["powershell", "-NoProfile", "-Command", "[Environment]::SetEnvironmentVariable('Path', '" + newPath.replace(/'/g, "''") + "', 'User')"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
1576
|
+
await setProc.exited;
|
|
1577
|
+
removed.push("Windows PATH entry");
|
|
1578
|
+
}
|
|
1579
|
+
} catch {}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
return removed;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async function isPortTaken() {
|
|
1586
|
+
try {
|
|
1587
|
+
var res = await fetch("http://127.0.0.1:" + PORT + "/", { signal: AbortSignal.timeout(500) });
|
|
1588
|
+
return res.ok;
|
|
1589
|
+
} catch { return false; }
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async function startBackground() {
|
|
1593
|
+
fbConnected = !!loadServiceAccount();
|
|
1594
|
+
|
|
1595
|
+
installOcLauncher().catch(function() {});
|
|
1596
|
+
|
|
1597
|
+
await tryClaimServer();
|
|
1598
|
+
|
|
1599
|
+
try { await pushToFirebase(getCachedSnapshot()); } catch {}
|
|
1600
|
+
|
|
1601
|
+
setInterval(async () => {
|
|
1602
|
+
try { await pushToFirebase(getCachedSnapshot()); } catch {}
|
|
1603
|
+
if (!ownsServer) { await tryClaimServer(); }
|
|
1604
|
+
}, SYNC_INTERVAL_MS);
|
|
1605
|
+
|
|
1606
|
+
setInterval(async () => {
|
|
1607
|
+
if (!ownsServer) { await tryClaimServer(); }
|
|
1608
|
+
}, 5000);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
async function tryClaimServer() {
|
|
1612
|
+
var taken = await isPortTaken();
|
|
1613
|
+
if (!taken) {
|
|
1614
|
+
try {
|
|
1615
|
+
Bun.serve({
|
|
1616
|
+
port: PORT,
|
|
1617
|
+
hostname: "127.0.0.1",
|
|
1618
|
+
async fetch(req) {
|
|
1619
|
+
var url = new URL(req.url);
|
|
1620
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
1621
|
+
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
1622
|
+
}
|
|
1623
|
+
if (url.pathname === "/api/data") {
|
|
1624
|
+
var localSnapshot = getCachedSnapshot();
|
|
1625
|
+
var remotes = await getRemoteSnapshots();
|
|
1626
|
+
var merged = mergeSnapshots(localSnapshot, remotes);
|
|
1627
|
+
merged.localDevice = DEVICE_ID;
|
|
1628
|
+
merged.firebaseConnected = fbConnected;
|
|
1629
|
+
merged.nicknames = await getNicknames();
|
|
1630
|
+
return Response.json(merged);
|
|
1631
|
+
}
|
|
1632
|
+
if (url.pathname === "/api/nickname" && req.method === "POST") {
|
|
1633
|
+
try {
|
|
1634
|
+
var body = await req.json();
|
|
1635
|
+
var ok = await setNicknameOnFirebase(body.device, body.nickname || "");
|
|
1636
|
+
return Response.json({ ok: ok });
|
|
1637
|
+
} catch { return Response.json({ ok: false }, { status: 400 }); }
|
|
1638
|
+
}
|
|
1639
|
+
if (url.pathname === "/api/device/remove" && req.method === "POST") {
|
|
1640
|
+
try {
|
|
1641
|
+
var body = await req.json();
|
|
1642
|
+
if (body.device === DEVICE_ID) return Response.json({ ok: false, error: "Cannot remove self" }, { status: 400 });
|
|
1643
|
+
var ok = await removeDeviceFromFirebase(body.device);
|
|
1644
|
+
return Response.json({ ok: ok });
|
|
1645
|
+
} catch { return Response.json({ ok: false }, { status: 400 }); }
|
|
1646
|
+
}
|
|
1647
|
+
if (url.pathname === "/api/account/toggle" && req.method === "POST") {
|
|
1648
|
+
try {
|
|
1649
|
+
var body = await req.json();
|
|
1650
|
+
var file = join(CONFIG_FOLDER, body.provider + "-accounts.json");
|
|
1651
|
+
if (!existsSync(file)) file = join(CONFIG_DIR, body.provider + "-accounts.json");
|
|
1652
|
+
if (existsSync(file)) {
|
|
1653
|
+
var raw = readJSON(file);
|
|
1654
|
+
if (raw && raw.accounts) {
|
|
1655
|
+
var changed = false;
|
|
1656
|
+
for (var i = 0; i < raw.accounts.length; i++) {
|
|
1657
|
+
if ((raw.accounts[i].email || raw.accounts[i].username || raw.accounts[i].id || body.provider) === body.email) {
|
|
1658
|
+
var currentEnabled = raw.accounts[i].enabled !== false;
|
|
1659
|
+
raw.accounts[i].enabled = !currentEnabled;
|
|
1660
|
+
if (raw.accounts[i].enabled === true) delete raw.accounts[i].enabled;
|
|
1661
|
+
changed = true;
|
|
1662
|
+
break;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
if (changed) {
|
|
1666
|
+
writeFileSync(file, JSON.stringify(raw, null, 2), "utf-8");
|
|
1667
|
+
snapshotCache.data = null;
|
|
1668
|
+
return Response.json({ ok: true });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return Response.json({ ok: false, error: "Account not found" }, { status: 404 });
|
|
1673
|
+
} catch { return Response.json({ ok: false }, { status: 400 }); }
|
|
1674
|
+
}
|
|
1675
|
+
if (url.pathname === "/api/account/toggle-all" && req.method === "POST") {
|
|
1676
|
+
try {
|
|
1677
|
+
var body = await req.json();
|
|
1678
|
+
var files = ["antigravity-accounts.json", "cursor-accounts.json", "zen-accounts.json"];
|
|
1679
|
+
var changedAny = false;
|
|
1680
|
+
for (var file of files) {
|
|
1681
|
+
var p = join(CONFIG_FOLDER, file);
|
|
1682
|
+
if (!existsSync(p)) p = join(CONFIG_DIR, file);
|
|
1683
|
+
if (existsSync(p)) {
|
|
1684
|
+
var raw = readJSON(p);
|
|
1685
|
+
if (raw && raw.accounts) {
|
|
1686
|
+
var changed = false;
|
|
1687
|
+
for (var i = 0; i < raw.accounts.length; i++) {
|
|
1688
|
+
var currentEnabled = raw.accounts[i].enabled !== false;
|
|
1689
|
+
if (currentEnabled !== body.enabled) {
|
|
1690
|
+
raw.accounts[i].enabled = body.enabled;
|
|
1691
|
+
if (raw.accounts[i].enabled === true) delete raw.accounts[i].enabled;
|
|
1692
|
+
changed = true;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
if (changed) {
|
|
1696
|
+
writeFileSync(p, JSON.stringify(raw, null, 2), "utf-8");
|
|
1697
|
+
changedAny = true;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
if (changedAny) snapshotCache.data = null;
|
|
1703
|
+
return Response.json({ ok: true });
|
|
1704
|
+
} catch { return Response.json({ ok: false }, { status: 400 }); }
|
|
1705
|
+
}
|
|
1706
|
+
return new Response("Not found", { status: 404 });
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
ownsServer = true;
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
var msg = String(err?.message || err?.code || err);
|
|
1712
|
+
if (!msg.includes("EADDRINUSE") && !msg.includes("address already in use")) {
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
export default async (ctx) => {
|
|
1719
|
+
setTimeout(startBackground, 0);
|
|
1720
|
+
|
|
1721
|
+
return {
|
|
1722
|
+
tool: {
|
|
1723
|
+
credit_dashboard: tool({
|
|
1724
|
+
description: "Get the URL of the credit usage dashboard. Shows account quotas and session costs across all synced devices.",
|
|
1725
|
+
args: {},
|
|
1726
|
+
async execute() {
|
|
1727
|
+
return "Credit usage dashboard: http://127.0.0.1:" + PORT;
|
|
1728
|
+
},
|
|
1729
|
+
}),
|
|
1730
|
+
oc_remove: tool({
|
|
1731
|
+
description: "Remove the oc launcher command. Deletes oc, oc.cmd, oc-tui.js from ~/.local/bin and removes PATH entries from shell rc files. The launcher will be reinstalled on next opencode start if the plugin is still active.",
|
|
1732
|
+
args: {},
|
|
1733
|
+
async execute() {
|
|
1734
|
+
var removed = await uninstallOcLauncher();
|
|
1735
|
+
if (!removed.length) return "Nothing to remove — oc launcher was not installed.";
|
|
1736
|
+
return "Removed oc launcher:\\n" + removed.join("\\n") + "\\n\\nNote: will be reinstalled on next opencode start if credit-dashboard plugin is still active.";
|
|
1737
|
+
},
|
|
1738
|
+
}),
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
};
|