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:
2026-10-23 11:26:12 +07:00
parent 9dd97a369c
commit 572d615d4e
9 changed files with 122 additions and 62 deletions
+14 -1
View File
@@ -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
View File
@@ -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,