diff --git a/README.md b/README.md index 27fa3a8..155551d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Auto-detects the report type from CSV headers. Drop one file or a whole zip. | Report | What you get | |---|---| -| **Metered Usage** | Actions minutes, runner SKU breakdown, Copilot seats, Packages, LFS, storage costs | +| **Metered Usage** | Actions minutes, runner SKU breakdown, Copilot seats, Packages, LFS, storage costs. Detailed and summarized exports both supported | | **Copilot Premium Requests** | Per-user PRU consumption, model breakdown, quota tracking, AI credit costs | | **Copilot Token Usage** | Input/output tokens, cache creation/read, per-model cost with token-level granularity | | **GHAS Active Committers** | Advanced Security license consumption by user and repository | @@ -51,9 +51,10 @@ Auto-detects the report type from CSV headers. Drop one file or a whole zip. - Falls back to clipboard if URL exceeds 8,000 characters **Privacy** -- 100% client-side. No server, no uploads, no telemetry +- Your CSVs never leave the browser. No server, no uploads, no telemetry - Reports cached in IndexedDB across sessions - Sample data auto-removed when real data is imported +- The one outbound request is an avatar lookup to `api.github.com` for names matching a bot pattern (`*[bot]`). Human usernames are never sent anywhere ## Quick Start @@ -66,7 +67,19 @@ Drop a CSV onto the upload area, or append `?demo=auto` to load sample data inst ### Where to Get the CSVs -GitHub billing admins can export usage reports from **Settings → Billing → Usage report → Download CSV**. The app auto-detects the report type from headers. +Each report lives in a different part of GitHub's billing and admin UI. The app auto-detects the type from the headers, so you never have to say which one you're uploading. + +| Report | Where to export it | +|---|---| +| **Metered Usage** | Billing & Licensing → Usage → Metered Usage → **Get usage report** | +| **Copilot Premium Requests** | Billing & Licensing → Usage → Premium request analytics → **Get usage report** | +| **Copilot Token Usage** | Billing & Licensing → Usage → Premium request analytics → **Get usage report** | +| **GHAS Active Committers** | Licensing → GitHub Advanced Security → **Download CSV Report** | +| **Copilot Seat Activity** | AI Controls → Copilot → Access Management → **Download CSV report** | +| **Dormant Users** | Settings → Compliance → **Export** | +| **Enterprise Members** | Licensing → **Export** (next to GitHub Enterprise) | + +Metered usage exports come in two shapes and both work: **detailed** (up to 31 days, includes the user and workflow that incurred the cost) and **summarized** (up to a year, drops those columns). Pick summarized for long-range trends, detailed when you need to attribute spend to a person or workflow. ## Tech Stack diff --git a/package.json b/package.json index 4d5d811..50c0faa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/src/context/ReportContext.tsx b/src/context/ReportContext.tsx index 2d22899..ba1bc44 100644 --- a/src/context/ReportContext.tsx +++ b/src/context/ReportContext.tsx @@ -4,7 +4,7 @@ import { ReportContext } from './report-context'; import type { CombinedGroup, DateRange } from './report-context'; import { getCachedParsedReports, getCachedRawCSV, setCachedCSV, removeCachedCSV, clearCachedCSVs } from '../lib/local-storage'; import { readURLFilterState, writeURLFilterState } from '../lib/url-state'; -import { getReportSchema } from '../lib/report-schema'; +import { getReportSchema, resolveGroupByColumn } from '../lib/report-schema'; import { formatDateRangeCompact } from '../lib/formatters'; /** Fast FNV-1a hash for dedup — not crypto-grade, just collision-resistant enough for CSV content */ @@ -202,7 +202,8 @@ export function ReportProvider({ children }: { children: ReactNode }) { reports: nextReports, rawCsvs: nextRawCsvs, fileHashes: nextHashes, - activeReportIndex: prev.reports.length, + activeReportIndex: baseReports.length, + groupByColumn: resolveGroupByColumn(report, prev.groupByColumn), periodKey: 'all', dateRange: null, searchQuery: '', diff --git a/src/lib/report-schema.test.ts b/src/lib/report-schema.test.ts index a8fbcba..faec0e3 100644 --- a/src/lib/report-schema.test.ts +++ b/src/lib/report-schema.test.ts @@ -5,6 +5,7 @@ import { PAGE_TYPES, PAGE_REPORT_TYPES, PRODUCT_METRIC_OPTIONS, + resolveGroupByColumn, } from './report-schema'; import { REPORT_TYPES } from './types'; @@ -77,3 +78,36 @@ describe('product metric options', () => { expect(actions.some(o => o.label === 'Minutes')).toBe(true); }); }); + +describe('resolveGroupByColumn', () => { + const report = (type: string, rows: Array>) => + ({ type, rows, fileName: 'f.csv', rowCount: rows.length, dateRange: { start: '', end: '' } }) as never; + + it('keeps the current column when the report has data for it', () => { + const r = report(REPORT_TYPES.USAGE_REPORT, [{ username: 'ana-reyes', sku: 'actions_linux' }]); + expect(resolveGroupByColumn(r, 'username')).toBe('username'); + }); + + it('falls back to the schema default when the column is blank for every row', () => { + const rows = Array.from({ length: 50 }, () => ({ username: '', sku: 'actions_linux' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('sku'); + }); + + it('falls back when the column is missing entirely, as in summarized exports', () => { + const rows = Array.from({ length: 50 }, () => ({ sku: 'actions_linux' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('sku'); + }); + + it('keeps a column that is blank early but populated later', () => { + const rows = Array.from({ length: 500 }, (_, i) => ({ username: i < 400 ? '' : 'kai-nakamura' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('username'); + }); + + it('uses the schema default when there is no current column', () => { + expect(resolveGroupByColumn(report(REPORT_TYPES.ENTERPRISE_MEMBERS, []), '')).toBe('licenseType'); + }); + + it('falls back for an empty report rather than keeping an unusable column', () => { + expect(resolveGroupByColumn(report(REPORT_TYPES.DORMANT_USERS, []), 'username')).toBe('role'); + }); +}); diff --git a/src/lib/report-schema.ts b/src/lib/report-schema.ts index 8d12dbc..024f5aa 100644 --- a/src/lib/report-schema.ts +++ b/src/lib/report-schema.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react'; -import type { ReportType } from './types'; +import type { ParsedReport, ReportType } from './types'; import { REPORT_TYPES } from './types'; import { CopilotIcon, @@ -347,6 +347,37 @@ export function getAllSchemas(): ReportSchema[] { return Object.values(SCHEMA_REGISTRY); } +const GROUP_BY_SAMPLE_SIZE = 200; + +/** + * Pick a group-by column that the report actually has data for. + * + * Reports of the same type don't always carry the same columns — the + * summarized metered usage export drops `username` and `workflow_path`, and + * org-level seat activity has no `organization`. Keeping a column that is + * blank for every row renders an empty chart, so fall back to the schema + * default. Rows are sampled with a stride rather than from the head, since + * columns like `username` are legitimately blank for leading storage rows. + */ +export function resolveGroupByColumn( + report: ParsedReport, + currentColumn: string, +): string { + const schema = getReportSchema(report.type); + if (!currentColumn) return schema.defaultGroupBy; + + const readColumn = (row: unknown): unknown => + (row as Record | undefined)?.[currentColumn]; + + const rows: readonly unknown[] = report.rows; + const stride = Math.max(1, Math.floor(rows.length / GROUP_BY_SAMPLE_SIZE)); + for (let i = 0; i < rows.length; i += stride) { + const value = readColumn(rows[i]); + if (value !== undefined && value !== null && value !== '') return currentColumn; + } + return schema.defaultGroupBy; +} + /** Sidebar nav page identifiers derived from report types */ export const PAGE_TYPES = { COPILOT: 'copilot', diff --git a/src/lib/sample-data.ts b/src/lib/sample-data.ts index c580c23..92b48f2 100644 --- a/src/lib/sample-data.ts +++ b/src/lib/sample-data.ts @@ -1,28 +1,34 @@ +import usageReportUrl from '../../examples/usageReport_1_7f2ed6006ee54fb8af73f5cbb7ac1f1d.csv?url'; +import premiumRequestUrl from '../../examples/premiumRequestUsageReport_1_c6fca30f0acd458098a95808eaf43399.csv?url'; +import tokenUsageUrl from '../../examples/Token.Usage.Report.csv?url'; +import seatActivityUrl from '../../examples/octodemo-seat-activity-1774680875.csv?url'; +import ghasCommittersUrl from '../../examples/ghas_active_committers_octodemo_2026-03-27T1521.csv?url'; +import dormantUsersUrl from '../../examples/export-octodemo-1774679438.csv?url'; +import enterpriseMembersUrl from '../../examples/export-octodemo-1774709193.csv?url'; + +const SAMPLES = [ + { name: 'usageReport.csv', url: usageReportUrl }, + { name: 'premiumRequestUsageReport.csv', url: premiumRequestUrl }, + { name: 'Token.Usage.Report.csv', url: tokenUsageUrl }, + { name: 'seat-activity.csv', url: seatActivityUrl }, + { name: 'ghas-active-committers.csv', url: ghasCommittersUrl }, + { name: 'dormant-users.csv', url: dormantUsersUrl }, + { name: 'enterprise-members.csv', url: enterpriseMembersUrl }, +] as const; + /** - * Lazily load example CSV files. These are code-split by Vite - * and only fetched when explicitly requested (e.g. from the tour prompt). - * Zero impact on normal users' bundle size. + * Load the example CSVs. These are emitted as static assets rather than + * imported with `?raw`, which would inline all 15 MB of them into a JS chunk + * the browser must download and parse as source before it can be a string. */ export async function loadSampleData(): Promise> { - const samples = await Promise.all([ - import('../../examples/usageReport_1_7f2ed6006ee54fb8af73f5cbb7ac1f1d.csv?raw').then( - (m) => ({ name: 'usageReport.csv', content: m.default }), - ), - import('../../examples/premiumRequestUsageReport_1_c6fca30f0acd458098a95808eaf43399.csv?raw').then( - (m) => ({ name: 'premiumRequestUsageReport.csv', content: m.default }), - ), - import('../../examples/Token.Usage.Report.csv?raw').then( - (m) => ({ name: 'Token.Usage.Report.csv', content: m.default }), - ), - import('../../examples/octodemo-seat-activity-1774680875.csv?raw').then( - (m) => ({ name: 'seat-activity.csv', content: m.default }), - ), - import('../../examples/ghas_active_committers_octodemo_2026-03-27T1521.csv?raw').then( - (m) => ({ name: 'ghas-active-committers.csv', content: m.default }), - ), - import('../../examples/export-octodemo-1774679438.csv?raw').then( - (m) => ({ name: 'enterprise-members.csv', content: m.default }), - ), - ]); - return samples; + return Promise.all( + SAMPLES.map(async ({ name, url }) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load sample ${name}: ${response.status} ${response.statusText}`); + } + return { name, content: await response.text() }; + }), + ); } diff --git a/src/lib/share-state.test.ts b/src/lib/share-state.test.ts index 36ad771..10d0fe6 100644 --- a/src/lib/share-state.test.ts +++ b/src/lib/share-state.test.ts @@ -8,7 +8,7 @@ describe('share URL round-trip', () => { it('buildShareURL → readShareData preserves filter state and CSV data', async () => { const { buildShareURL, readShareData } = await import('./share-state'); Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:5174', pathname: '/tbb/' }, + value: { origin: 'http://localhost:5174', pathname: '/github-actions-usage-report/' }, writable: true, }); @@ -37,7 +37,7 @@ describe('share URL round-trip', () => { it('returns null for oversized payloads', async () => { const { buildShareURL } = await import('./share-state'); Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:5174', pathname: '/tbb/' }, + value: { origin: 'http://localhost:5174', pathname: '/github-actions-usage-report/' }, writable: true, }); @@ -62,16 +62,16 @@ describe('clearShareHash', () => { it('strips share hash and preserves search params', () => { Object.defineProperty(window, 'location', { - value: { hash: '#data=xyz', pathname: '/tbb/', search: '?tab=table' }, + value: { hash: '#data=xyz', pathname: '/github-actions-usage-report/', search: '?tab=table' }, writable: true, }); clearShareHash(); - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/tbb/?tab=table'); + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/github-actions-usage-report/?tab=table'); }); it('ignores non-share hashes', () => { Object.defineProperty(window, 'location', { - value: { hash: '#section', pathname: '/tbb/', search: '' }, + value: { hash: '#section', pathname: '/github-actions-usage-report/', search: '' }, writable: true, }); clearShareHash();