chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update
- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions, sub-agent files, baselines and long-context pricing were missing. Now booked by hourlyTokenRows + calculateCost like the cost routes. - Update: a page adopts a plugin update the server reports as running (after a reload, another tab, or one started from an older bundle), and the progress chip shows whenever an update runs and the modal is not visible — also when the status stops offering it mid-run.
This commit is contained in:
@@ -57,10 +57,22 @@ before(async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
|
||||
).run(new Date().toISOString().slice(0, 13));
|
||||
// A tool with no hourly rows (omp, Codex…): its total counts from its start.
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('so', 'mia omp', 'active', '/w/mia', ?, 'omp')"
|
||||
).run(lane);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES ('so', 'claude-sonnet-4-5-20250929', 500000)`
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
@@ -76,7 +88,8 @@ describe("staff views", () => {
|
||||
assert.equal(r.status, 200);
|
||||
const mia = r.body.users.find((u) => u.username === "mia");
|
||||
assert.equal(mia.workspaces, 1);
|
||||
assert.equal(mia.sessions, 1);
|
||||
assert.equal(mia.sessions, 2);
|
||||
assert.equal(mia.today.tokens, 1500000, "a session with no hourly rows counts too");
|
||||
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
||||
// This hour's spend is today's in every timezone.
|
||||
assert.equal(mia.today.cost, mia.last7d.cost);
|
||||
|
||||
+41
-46
@@ -4,7 +4,7 @@
|
||||
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
||||
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
|
||||
* and in the last 7 and 30 days (from `token_usage_hourly`, sessions owned
|
||||
* and in the last 7 and 30 days (`hourlyTokenRows` + `calculateCost`, sessions owned
|
||||
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||
@@ -42,22 +42,6 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const USAGE_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
"long_input_tokens",
|
||||
"long_output_tokens",
|
||||
"long_cache_read_tokens",
|
||||
"long_cache_write_tokens",
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
@@ -69,33 +53,43 @@ const OWNED_SESSIONS = `
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
|
||||
/** Map(user id → {cost, tokens}) every account's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(sinceHour, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`WITH owned AS (${OWNED_SESSIONS})
|
||||
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
|
||||
WHERE h.hour >= ?
|
||||
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(sinceHour);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
|
||||
byUser.get(r.uid).push(r);
|
||||
/**
|
||||
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||
* (`YYYY-MM-DDTHH`) it starts at. Spend is the pricing routes' own booking
|
||||
* (`hourlyTokenRows`: a session's hours, the rest of its total at its start
|
||||
* hour — so Codex/omp/Antigravity and sub-agent usage count too) priced by
|
||||
* `calculateCost`, never a second formula.
|
||||
*/
|
||||
function usageSince(windows, rules) {
|
||||
const { calculateCost, hourlyTokenRows } = require("./pricing");
|
||||
const oldest = windows.reduce((a, b) => (b < a ? b : a));
|
||||
const owner = new Map();
|
||||
for (const r of db.prepare(OWNED_SESSIONS).all()) {
|
||||
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||
}
|
||||
const out = new Map();
|
||||
for (const [uid, list] of byUser) {
|
||||
const tokens = list.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
|
||||
}
|
||||
return out;
|
||||
// A session that spent in the window was touched in it (budgets.js does the same).
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
|
||||
[oldest]
|
||||
);
|
||||
return windows.map((since) => {
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
const uid = owner.get(r.session_id);
|
||||
if (uid == null || r.date < since) continue;
|
||||
if (!byUser.has(uid)) byUser.set(uid, []);
|
||||
byUser.get(uid).push(r);
|
||||
}
|
||||
const out = new Map();
|
||||
for (const [uid, list] of byUser) {
|
||||
const tokens = list.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens: Math.round(tokens) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||
@@ -125,9 +119,10 @@ router.get("/", (req, res) => {
|
||||
const sessions = counts(
|
||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
||||
);
|
||||
const today = usageSince(todayHour(parseInt(req.query.tz_offset, 10)), rules);
|
||||
const last7d = usageSince(hourAgo(7), rules);
|
||||
const last30d = usageSince(hourAgo(30), rules);
|
||||
const [today, last7d, last30d] = usageSince(
|
||||
[todayHour(parseInt(req.query.tz_offset, 10)), hourAgo(7), hourAgo(30)],
|
||||
rules
|
||||
);
|
||||
const none = { cost: 0, tokens: 0 };
|
||||
const users = listUsers().map((u) => ({
|
||||
...u,
|
||||
|
||||
Reference in New Issue
Block a user