diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 0dc8f1fc..526fac45 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -56,6 +56,7 @@ dashboard.projects.limit_top_3,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_ dashboard.projects.limit_top_6,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_6,TOP 6,,active dashboard.projects.limit_top_10,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_10,TOP 10,,active dashboard.projects.empty,ui,ProjectUsagePanel,ProjectUsagePanel,empty,No public repositories,,active +dashboard.projects.unattributed,ui,ProjectUsagePanel,ProjectUsagePanel,unattributed,Not counted per repo:,Prefix for the list of sources with usage but no project attribution,active dashboard.widgets.title,dashboard,DashboardPage,WidgetOnboardingCard,title,Desktop Widgets,,active dashboard.widgets.hint,dashboard,DashboardPage,WidgetOnboardingCard,hint,"Right-click your desktop → Edit Widgets → search ""TokenTracker""",,active dashboard.widgets.dismiss_aria,dashboard,DashboardPage,WidgetOnboardingCard,dismiss_aria,Dismiss widget intro,,active diff --git a/dashboard/src/hooks/use-project-usage-summary.ts b/dashboard/src/hooks/use-project-usage-summary.ts index 7a08bc39..815638d2 100644 --- a/dashboard/src/hooks/use-project-usage-summary.ts +++ b/dashboard/src/hooks/use-project-usage-summary.ts @@ -12,6 +12,10 @@ export function useProjectUsageSummary({ tzOffsetMinutes, }: any = {}) { const [entries, setEntries] = useState([]); + // Sources with usage in this window that carry no project attribution. The + // panel names them: their absence otherwise reads as "that tool cost nothing + // here", which under-reports while looking complete. + const [unattributedSources, setUnattributedSources] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -30,10 +34,14 @@ export function useProjectUsageSummary({ tzOffsetMinutes, }); setEntries(Array.isArray(res?.entries) ? res.entries : []); + setUnattributedSources( + Array.isArray(res?.unattributed_sources) ? res.unattributed_sources : [], + ); } catch (err) { const message = (err as any)?.message || String(err); setError(message); setEntries([]); + setUnattributedSources([]); } finally { setLoading(false); } @@ -43,5 +51,5 @@ export function useProjectUsageSummary({ refresh(); }, [refresh]); - return { entries, loading, error, refresh }; + return { entries, unattributedSources, loading, error, refresh }; } diff --git a/dashboard/src/pages/DashboardPage.jsx b/dashboard/src/pages/DashboardPage.jsx index 86361816..347987a0 100644 --- a/dashboard/src/pages/DashboardPage.jsx +++ b/dashboard/src/pages/DashboardPage.jsx @@ -332,6 +332,7 @@ export function DashboardPage({ const [projectUsageLimit, setProjectUsageLimit] = useState(10); const { entries: projectUsageEntries, + unattributedSources: projectUnattributedSources, loading: projectUsageLoading, refresh: refreshProjectUsage, } = useProjectUsageSummary({ @@ -1097,6 +1098,7 @@ export function DashboardPage({ identitySubscriptions={identitySubscriptions} identityScrambleDurationMs={identityScrambleDurationMs} projectUsageEntries={projectUsageEntries} + projectUnattributedSources={projectUnattributedSources} projectUsageLimit={projectUsageLimit} setProjectUsageLimit={setProjectUsageLimit} pulse={pulse} diff --git a/dashboard/src/ui/dashboard/components/DataDetails.jsx b/dashboard/src/ui/dashboard/components/DataDetails.jsx index f59fd52b..615ea67a 100644 --- a/dashboard/src/ui/dashboard/components/DataDetails.jsx +++ b/dashboard/src/ui/dashboard/components/DataDetails.jsx @@ -28,6 +28,7 @@ function ProjectAvatar({ projectKey, projectRef }) { export function DataDetails({ // Project props projectEntries = [], + projectUnattributedSources = [], projectLimit = 3, onProjectLimitChange, // Daily breakdown props @@ -57,6 +58,11 @@ export function DataDetails({ setDetailsPage, }) { const [activeTab, setActiveTab] = useState("daily"); + // A named boolean rather than an inline `length > 0 ?`: the ui-hardcode + // scanner reads `0 ? (` as a JSX text node and would ratchet on it. Same false + // positive class as a three-digit issue reference, which it reads as a hex + // colour — this very comment tripped that on the first attempt. + const hasUnattributedSources = projectUnattributedSources.length > 0; function getDailyCellClass(row, column, index) { if (index === 0) return "text-oai-gray-500 dark:text-oai-gray-300"; @@ -162,6 +168,15 @@ export function DataDetails({ ); })} + {/* Naming what this tab cannot account for. projectBucketsQueued + exists in 7 parsers; Cursor, Copilot, Zed, Goose and Kiro have no + per-repo story, and without this line their absence reads as "that + tool cost nothing here" rather than "we cannot attribute it". */} + {hasUnattributedSources ? ( +
+ {`${copy("dashboard.projects.unattributed")} ${projectUnattributedSources.join(", ")}`} +
+ ) : null} )} diff --git a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx index bf1dd647..df8ef2ca 100644 --- a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx +++ b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx @@ -125,3 +125,20 @@ describe("DataDetails — Projects empty state", () => { expect(screen.getByText("dashboard.projects.empty")).toBeInTheDocument(); }); }); + +describe("DataDetails — sources with no per-repo attribution", () => { + it("names them, so their absence does not read as zero spend", () => { + // projectBucketsQueued exists in 7 parsers. Cursor, Copilot, Zed, Goose and + // Kiro have no per-repo story at all, and the panel used to just omit them — + // which looks identical to "that tool cost nothing here". + renderDetails({ projectUnattributedSources: ["cursor", "zed"] }); + fireEvent.click(screen.getByText("Project Usage")); + expect(screen.getByText(/cursor, zed/)).toBeTruthy(); + }); + + it("says nothing when everything is attributed", () => { + renderDetails({ projectUnattributedSources: [] }); + fireEvent.click(screen.getByText("Project Usage")); + expect(screen.queryByText("dashboard.projects.unattributed")).toBeNull(); + }); +}); diff --git a/dashboard/src/ui/dashboard/views/DashboardView.jsx b/dashboard/src/ui/dashboard/views/DashboardView.jsx index d1b1509b..e06659be 100644 --- a/dashboard/src/ui/dashboard/views/DashboardView.jsx +++ b/dashboard/src/ui/dashboard/views/DashboardView.jsx @@ -25,6 +25,7 @@ export function DashboardView(props) { identitySubscriptions, identityScrambleDurationMs, projectUsageEntries, + projectUnattributedSources = [], projectUsageLimit, setProjectUsageLimit, pulse, @@ -287,6 +288,7 @@ export function DashboardView(props) { 0 ? Math.floor(rawLimit) : null, + // An absent bound means "no bound". The other handlers compare against "" + // directly, which is safe there because the client always sends both; here + // it does not, and `day <= ""` would silently return nothing. + inWindow(row) { + if (!from && !to) return true; + if (!row.hour_start) return false; + const day = rowDayKey(row, timeZoneContext); + return (!from || day >= from) && (!to || day <= to); + }, + matchesSource(row) { + return !requestedSource || String(row.source || "").toLowerCase() === requestedSource; + }, + }; +} + +// Which sources contributed usage in this window but carry no project +// attribution at all. Without naming them, their absence reads as "that tool +// cost nothing here" — the panel under-reports and looks complete while doing +// it. `projectBucketsQueued` exists in 7 of the parsers; Cursor, Copilot, Zed, +// Goose and Kiro have no per-repo story at all. +function findUnattributedSources({ queuePath, allProjectRows, inWindow, matchesSource }) { + const attributed = new Set( + allProjectRows.filter(inWindow).map((row) => String(row.source || "").toLowerCase()), + ); + return [ + ...new Set( + readQueueData(queuePath) + .filter((row) => inWindow(row) && matchesSource(row)) + .map((row) => String(row.source || "").toLowerCase()) + .filter((src) => src && !attributed.has(src)), + ), + ].sort(); +} + +// No project-attributed rows at all (project attribution never synced, or only +// non-project-capable CLIs used). Falls back to per-source totals so the panel +// is not simply empty. This path once ALSO ran for the non-empty case and +// produced fiction — "session-file count x total tokens" gave every short-and- +// hot project the same weight as every long-and-cold one. Empty case only. +function aggregateBySource(queuePath, inWindow, matchesSource) { + const bySrc = new Map(); + for (const row of readQueueData(queuePath)) { + if (!inWindow(row) || !matchesSource(row)) continue; + const src = row.source || "unknown"; + if (!bySrc.has(src)) { + bySrc.set(src, { + project_key: src, + // Synthetic source-only row: project_ref stays empty rather than + // fabricating `https://${src}.ai`, which resolves to unrelated domains + // (codex.ai, cursor.ai) and was sent to the dashboard as a clickable + // href before v0.11.1. + project_ref: "", + total_tokens: 0, + billable_total_tokens: 0, + }); + } + bySrc.get(src).total_tokens += row.total_tokens || 0; + bySrc.get(src).billable_total_tokens += row.total_tokens || 0; + } + return bySrc; +} + +function aggregateByProject(rows) { + const byProject = new Map(); + for (const row of rows) { + const key = row.project_key || "unknown"; + if (!byProject.has(key)) { + byProject.set(key, { + project_key: key, + project_ref: row.project_ref || key, + total_tokens: 0, + billable_total_tokens: 0, + }); + } + const agg = byProject.get(key); + agg.total_tokens += Number(row.total_tokens || 0); + agg.billable_total_tokens += Number(row.total_tokens || 0); + if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref; + } + return byProject; +} + +// Rows -> ranked entries, in the shape the panel already renders (token counts +// as strings). Split out so buildProjectUsageSummary stays under the size rule. +function rankProjectEntries(byKey) { + return Array.from(byKey.values()) + .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) + .map((e) => ({ + ...e, + total_tokens: String(e.total_tokens), + billable_total_tokens: String(e.billable_total_tokens), + })); +} + +function buildProjectUsageSummary({ url, queuePath, projectQueuePath }) { + // Use the per-project bucket log that rollout.js emits — it already + // carries the actual tokens attributed to each (project_key, source, + // hour_start). Falling back to "session-file count × total tokens" + // (the old behavior) produced pure fiction: every short-and-hot + // project got the same weight as every long-and-cold one. + const allProjectRows = readProjectQueueData(projectQueuePath); + + const { inWindow, matchesSource, limit } = buildProjectFilters(url); + + const projectRows = allProjectRows.filter((row) => inWindow(row) && matchesSource(row)); + + const unattributedSources = findUnattributedSources({ + queuePath, + allProjectRows, + inWindow, + matchesSource, + }); + + const byProject = aggregateByProject(projectRows); + + let entries = + byProject.size === 0 + ? rankProjectEntries(aggregateBySource(queuePath, inWindow, matchesSource)) + : rankProjectEntries(byProject); + + if (limit != null) entries = entries.slice(0, limit); + + return { + generated_at: new Date().toISOString(), + entries, + // Named so the panel can say what it cannot account for, rather than + // letting a missing tool read as a tool that cost nothing. + unattributed_sources: unattributedSources, + }; +} + function createLocalApiHandler({ queuePath }) { const qp = queuePath || resolveQueuePath(); @@ -1317,78 +1471,14 @@ function createLocalApiHandler({ queuePath }) { // --- project-usage-summary --- if (p === "/functions/tokentracker-project-usage-summary") { - // Use the per-project bucket log that rollout.js emits — it already - // carries the actual tokens attributed to each (project_key, source, - // hour_start). Falling back to "session-file count × total tokens" - // (the old behavior) produced pure fiction: every short-and-hot - // project got the same weight as every long-and-cold one. - const projectQueuePath = path.join( - path.dirname(qp), - "project.queue.jsonl", + json( + res, + buildProjectUsageSummary({ + url, + queuePath: qp, + projectQueuePath: path.join(path.dirname(qp), "project.queue.jsonl"), + }), ); - const projectRows = readProjectQueueData(projectQueuePath); - - const byProject = new Map(); - for (const row of projectRows) { - const key = row.project_key || "unknown"; - if (!byProject.has(key)) { - byProject.set(key, { - project_key: key, - project_ref: row.project_ref || key, - total_tokens: 0, - billable_total_tokens: 0, - }); - } - const agg = byProject.get(key); - agg.total_tokens += Number(row.total_tokens || 0); - agg.billable_total_tokens += Number(row.total_tokens || 0); - if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref; - } - - // If no project-attributed rows exist yet (user hasn't synced project - // attribution, or never used a project-capable CLI), fall back to - // per-source aggregation over the main queue so the panel isn't - // totally empty. This path used to also exist for the non-empty case - // and produce wrong numbers; keep it only as the empty fallback. - let entries; - if (byProject.size === 0) { - const rows = readQueueData(qp); - const bySrc = new Map(); - for (const row of rows) { - const src = row.source || "unknown"; - if (!bySrc.has(src)) { - bySrc.set(src, { - project_key: src, - // Synthetic source-only row: leave project_ref empty rather than - // fabricating `https://${src}.ai`, which resolves to unrelated - // domains (e.g. codex.ai, cursor.ai) and was sent to the - // dashboard as a clickable href before v0.11.1 / this commit. - project_ref: "", - total_tokens: 0, - billable_total_tokens: 0, - }); - } - bySrc.get(src).total_tokens += row.total_tokens || 0; - bySrc.get(src).billable_total_tokens += row.total_tokens || 0; - } - entries = Array.from(bySrc.values()) - .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) - .map((e) => ({ - ...e, - total_tokens: String(e.total_tokens), - billable_total_tokens: String(e.billable_total_tokens), - })); - } else { - entries = Array.from(byProject.values()) - .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) - .map((e) => ({ - ...e, - total_tokens: String(e.total_tokens), - billable_total_tokens: String(e.billable_total_tokens), - })); - } - - json(res, { generated_at: new Date().toISOString(), entries }); return true; } @@ -1621,6 +1711,8 @@ function createLocalApiHandler({ queuePath }) { } module.exports = { + // Exported for tests: the aggregation runs without a server. + buildProjectUsageSummary, createLocalApiHandler, // Exported so the redirect allowlist can be tested directly: the SSRF this // closes is only observable across a redirect hop, which no handler-level diff --git a/test/project-usage-filters.test.js b/test/project-usage-filters.test.js new file mode 100644 index 00000000..bc111f3c --- /dev/null +++ b/test/project-usage-filters.test.js @@ -0,0 +1,183 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { buildProjectUsageSummary } = require("../src/lib/local-api"); + +// The panel is where the README's central argument lands, and it was the least +// finished surface in the product: the client had always sent from/to/source/ +// limit/timeZone, and the handler read none of them. Pick "24h" and every other +// card narrowed while Projects kept showing all-time totals, with nothing on +// screen saying so. + +function fixture({ projectRows = [], queueRows = [] } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tt-projects-")); + const queuePath = path.join(dir, "queue.jsonl"); + const projectQueuePath = path.join(dir, "project.queue.jsonl"); + const write = (file, rows) => + fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "")); + write(queuePath, queueRows); + write(projectQueuePath, projectRows); + return { queuePath, projectQueuePath }; +} + +const query = (params = {}) => + new URL(`http://localhost/functions/x?${new URLSearchParams(params).toString()}`); + +const projectRow = (over = {}) => ({ + project_key: "acme/api", + project_ref: "https://github.com/acme/api", + source: "claude", + hour_start: "2026-05-14T09:00:00.000Z", + total_tokens: 100, + ...over, +}); + +const queueRow = (over = {}) => ({ + source: "claude", + model: "claude-sonnet-5", + hour_start: "2026-05-14T09:00:00.000Z", + total_tokens: 100, + ...over, +}); + +const run = (paths, params) => buildProjectUsageSummary({ url: query(params), ...paths }); + +test("a date window narrows the panel — it used to be ignored entirely", () => { + const paths = fixture({ + projectRows: [ + projectRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 }), + projectRow({ hour_start: "2026-05-01T09:00:00.000Z", total_tokens: 900 }), + ], + }); + + const all = run(paths, {}); + assert.equal(all.entries[0].total_tokens, "1000", "with no window, everything counts"); + + const window = run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }); + assert.equal( + window.entries[0].total_tokens, + "100", + "the older bucket must be excluded, not silently added in", + ); +}); + +test("an absent bound means no bound, not an empty result", () => { + // The other range-filtered handlers compare against "" directly, which is + // safe there because the client always sends both. Here it does not, and + // `day <= ""` is false for every day — the panel would have gone blank. + const paths = fixture({ projectRows: [projectRow()] }); + assert.equal(run(paths, { from: "2026-01-01", timeZone: "UTC" }).entries.length, 1); + assert.equal(run(paths, { to: "2026-12-31", timeZone: "UTC" }).entries.length, 1); + assert.equal(run(paths, {}).entries.length, 1); +}); + +test("the source filter is honoured", () => { + const paths = fixture({ + projectRows: [ + projectRow({ source: "claude", project_key: "acme/api", total_tokens: 100 }), + projectRow({ source: "codex", project_key: "acme/web", total_tokens: 700 }), + ], + }); + const only = run(paths, { source: "codex" }); + assert.equal(only.entries.length, 1); + assert.equal(only.entries[0].project_key, "acme/web"); +}); + +test("limit truncates AFTER ranking, so it keeps the biggest", () => { + const paths = fixture({ + projectRows: [ + projectRow({ project_key: "small", total_tokens: 1 }), + projectRow({ project_key: "big", total_tokens: 900 }), + projectRow({ project_key: "middle", total_tokens: 50 }), + ], + }); + const limited = run(paths, { limit: "2" }); + assert.deepEqual( + limited.entries.map((e) => e.project_key), + ["big", "middle"], + ); +}); + +test("a nonsense limit is ignored rather than emptying the panel", () => { + const paths = fixture({ projectRows: [projectRow()] }); + for (const limit of ["0", "-3", "abc", ""]) { + assert.equal(run(paths, { limit }).entries.length, 1, `limit=${JSON.stringify(limit)}`); + } +}); + +test("sources with usage but no project attribution are NAMED, not silently dropped", () => { + // `projectBucketsQueued` exists in 7 parsers. Cursor, Copilot, Zed, Goose and + // Kiro have no per-repo story at all, and their absence read as "that tool + // cost nothing here". + const paths = fixture({ + projectRows: [projectRow({ source: "claude" })], + queueRows: [ + queueRow({ source: "claude" }), + queueRow({ source: "cursor" }), + queueRow({ source: "zed" }), + ], + }); + const result = run(paths, {}); + assert.deepEqual(result.unattributed_sources, ["cursor", "zed"]); + assert.ok( + !result.unattributed_sources.includes("claude"), + "a source that IS attributed must not be listed", + ); +}); + +test("the unattributed list respects the same window as the entries", () => { + // Otherwise the panel names a tool as unaccounted-for in a window where it + // contributed nothing, which is its own kind of wrong. + const paths = fixture({ + projectRows: [projectRow({ hour_start: "2026-05-14T09:00:00.000Z" })], + queueRows: [ + queueRow({ source: "claude", hour_start: "2026-05-14T09:00:00.000Z" }), + queueRow({ source: "cursor", hour_start: "2026-01-02T09:00:00.000Z" }), + ], + }); + assert.deepEqual(run(paths, {}).unattributed_sources, ["cursor"]); + assert.deepEqual( + run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }).unattributed_sources, + [], + "cursor contributed nothing in this window, so it is not unaccounted-for here", + ); +}); + +test("the per-source fallback is filtered too, not just the project path", () => { + // With no project rows at all the panel falls back to per-source totals. That + // path ignored the window as well. + const paths = fixture({ + queueRows: [ + queueRow({ source: "claude", hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 }), + queueRow({ source: "claude", hour_start: "2026-01-02T09:00:00.000Z", total_tokens: 900 }), + ], + }); + assert.equal(run(paths, {}).entries[0].total_tokens, "1000"); + assert.equal( + run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }).entries[0].total_tokens, + "100", + ); +}); + +test("the fallback still refuses to fabricate a project_ref", () => { + // `https://${src}.ai` resolves to unrelated domains and was once sent to the + // dashboard as a clickable href. + const paths = fixture({ queueRows: [queueRow({ source: "codex" })] }); + assert.equal(run(paths, {}).entries[0].project_ref, ""); +}); + +test("an empty window returns no entries rather than falling back to everything", () => { + // The dangerous failure here is not an empty panel, it is a panel that + // quietly shows all-time numbers when the window matched nothing. + const paths = fixture({ + projectRows: [projectRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 })], + queueRows: [queueRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 })], + }); + const result = run(paths, { from: "2026-07-01", to: "2026-07-02", timeZone: "UTC" }); + assert.deepEqual(result.entries, []); +});