Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/context/ReportContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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: '',
Expand Down
34 changes: 34 additions & 0 deletions src/lib/report-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PAGE_TYPES,
PAGE_REPORT_TYPES,
PRODUCT_METRIC_OPTIONS,
resolveGroupByColumn,
} from './report-schema';
import { REPORT_TYPES } from './types';

Expand Down Expand Up @@ -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<Record<string, unknown>>) =>
({ 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');
});
});
33 changes: 32 additions & 1 deletion src/lib/report-schema.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, unknown> | 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',
Expand Down
54 changes: 30 additions & 24 deletions src/lib/sample-data.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ name: string; content: string }>> {
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() };
}),
);
}
10 changes: 5 additions & 5 deletions src/lib/share-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
});

Expand All @@ -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();
Expand Down
Loading