Skip to content

Commit 1584d77

Browse files
authored
Merge pull request #9 from github/feat/products-view
feat: add products view with per-product usage breakdown
2 parents d44991a + 34abe88 commit 1584d77

9 files changed

Lines changed: 616 additions & 132 deletions

File tree

src/App.css

Lines changed: 292 additions & 121 deletions
Large diffs are not rendered by default.

src/App.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { OrganizationsView } from './views/OrganizationsView'
99
import { ModelsView } from './views/ModelsView'
1010
import { ReportGuideView } from './views/ReportGuideView'
1111
import { FaqView } from './views/FaqView'
12+
import { ProductsView } from './views/ProductsView'
1213
import { QuickStatsAggregator, type QuickStatsResult } from './pipeline/aggregators/quickStatsAggregator'
1314
import { ReportContextAggregator, type ReportContextResult } from './pipeline/aggregators/reportContextAggregator'
1415
import { DailyUsageAggregator, type DailyUsageData } from './pipeline/aggregators/dailyUsageAggregator'
1516
import { ModelUsageAggregator, type ModelUsageResult } from './pipeline/aggregators/modelUsageAggregator'
17+
import { ProductUsageAggregator, type ProductUsageResult } from './pipeline/aggregators/productUsageAggregator'
1618
import { CostCenterAggregator, type CostCenterResult } from './pipeline/aggregators/costCenterAggregator'
1719
import { OrganizationAggregator, type OrganizationResult } from './pipeline/aggregators/organizationAggregator'
1820
import { UserUsageAggregator, type UserUsageResult } from './pipeline/aggregators/userUsageAggregator'
@@ -55,9 +57,10 @@ function App() {
5557
const [fileName, setFileName] = useState<string | null>(null)
5658
const [dragActive, setDragActive] = useState(false)
5759
const [dailyUsageData, setDailyUsageData] = useState<DailyUsageData[]>([])
58-
const [activeView, setActiveView] = useState<'overview' | 'users' | 'userDetails' | 'costCenters' | 'orgs' | 'models' | 'guide' | 'faq'>('overview')
60+
const [activeView, setActiveView] = useState<'overview' | 'users' | 'userDetails' | 'costCenters' | 'orgs' | 'models' | 'products' | 'guide' | 'faq'>('overview')
5961
const [userUsage, setUserUsage] = useState<UserUsageResult | null>(null)
6062
const [modelUsage, setModelUsage] = useState<ModelUsageResult | null>(null)
63+
const [productUsage, setProductUsage] = useState<ProductUsageResult | null>(null)
6164
const [selectedUsername, setSelectedUsername] = useState<string>('')
6265
const [costCenters, setCostCenters] = useState<CostCenterResult | null>(null)
6366
const [orgs, setOrgs] = useState<OrganizationResult | null>(null)
@@ -71,6 +74,7 @@ function App() {
7174
setDailyUsageData([])
7275
setUserUsage(null)
7376
setModelUsage(null)
77+
setProductUsage(null)
7478
setSelectedUsername('')
7579
setCostCenters(null)
7680
setOrgs(null)
@@ -82,6 +86,7 @@ function App() {
8286
const contextAggregator = new ReportContextAggregator()
8387
const dailyAggregator = new DailyUsageAggregator()
8488
const modelAggregator = new ModelUsageAggregator()
89+
const productAggregator = new ProductUsageAggregator()
8590
const costCenterAggregator = new CostCenterAggregator()
8691
const orgAggregator = new OrganizationAggregator()
8792
const userAggregator = new UserUsageAggregator()
@@ -91,6 +96,7 @@ function App() {
9196
contextAggregator,
9297
dailyAggregator,
9398
modelAggregator,
99+
productAggregator,
94100
costCenterAggregator,
95101
orgAggregator,
96102
userAggregator,
@@ -102,6 +108,7 @@ function App() {
102108

103109
const nextModelUsage = modelAggregator.result()
104110
setModelUsage(nextModelUsage)
111+
setProductUsage(productAggregator.result())
105112

106113
setCostCenters(costCenterAggregator.result())
107114
setOrgs(orgAggregator.result())
@@ -292,6 +299,17 @@ function App() {
292299
</button>
293300
)}
294301

302+
<button
303+
type="button"
304+
className={`sidebar__item ${activeView === 'products' ? 'sidebar__item--active' : ''}`}
305+
onClick={() => setActiveView('products')}
306+
>
307+
<svg className="sidebar__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
308+
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" />
309+
</svg>
310+
<span className="sidebar__label">Products</span>
311+
</button>
312+
295313
{orgs && orgs.organizations.length > 0 && (
296314
<button
297315
type="button"
@@ -321,6 +339,8 @@ function App() {
321339
</button>
322340
)}
323341

342+
<hr className="sidebar__divider" />
343+
324344
<button
325345
type="button"
326346
className={`sidebar__item ${activeView === 'guide' ? 'sidebar__item--active' : ''}`}
@@ -554,6 +574,10 @@ function App() {
554574
<div className="view-content">
555575
<CostCentersView data={costCenters ?? { costCenters: [] }} rangeStart={rangeStart} />
556576
</div>
577+
) : activeView === 'products' ? (
578+
<div className="view-content">
579+
<ProductsView data={productUsage ?? { products: [] }} />
580+
</div>
557581
) : activeView === 'guide' ? (
558582
<div className="view-content">
559583
<ReportGuideView />

src/pipeline/aggregators/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export { DailyUsageAggregator } from './dailyUsageAggregator'
66
export type { DailyUsageData, DailyUsageResult } from './dailyUsageAggregator'
77
export { ModelUsageAggregator } from './modelUsageAggregator'
88
export type { ModelDailyUsageData, ModelUsageResult, ModelUsageTotals } from './modelUsageAggregator'
9+
export { ProductUsageAggregator } from './productUsageAggregator'
10+
export type { ProductUsage, ProductUsageResult, ProductUsageTotals } from './productUsageAggregator'
911
export { CostCenterAggregator } from './costCenterAggregator'
1012
export type { CostCenterResult, CostCenterUsage, CostTotals } from './costCenterAggregator'
1113
export { OrganizationAggregator } from './organizationAggregator'
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Aggregator } from './base'
2+
import { getUsageMetrics, type TokenUsageHeader, type TokenUsageRecord } from '../parser'
3+
4+
export type ProductUsageTotals = {
5+
requests: number
6+
aiuQuantity: number
7+
grossAmount: number
8+
aiuGrossAmount: number
9+
}
10+
11+
export type ProductUsage = {
12+
product: string
13+
totals: ProductUsageTotals
14+
models: Record<string, ProductUsageTotals>
15+
}
16+
17+
export type ProductUsageResult = {
18+
products: ProductUsage[]
19+
}
20+
21+
function createTotals(): ProductUsageTotals {
22+
return {
23+
requests: 0,
24+
aiuQuantity: 0,
25+
grossAmount: 0,
26+
aiuGrossAmount: 0,
27+
}
28+
}
29+
30+
function getFriendlyProductName(record: TokenUsageRecord): string {
31+
const model = record.model.trim()
32+
33+
if (model === 'Code Review model') return 'Code Review'
34+
if (model === 'Coding Agent model') return 'Coding Agent'
35+
return 'Copilot'
36+
}
37+
38+
export class ProductUsageAggregator implements Aggregator<TokenUsageRecord, ProductUsageResult, TokenUsageHeader> {
39+
private readonly byProduct = new Map<string, { totals: ProductUsageTotals; models: Map<string, ProductUsageTotals> }>()
40+
41+
onHeader(): void {
42+
// header is intentionally ignored (we rely on parsed TokenUsageRecord fields)
43+
}
44+
45+
accumulate(record: TokenUsageRecord): void {
46+
const product = getFriendlyProductName(record)
47+
const model = record.model.trim() || 'Unknown'
48+
49+
let productUsage = this.byProduct.get(product)
50+
if (!productUsage) {
51+
productUsage = {
52+
totals: createTotals(),
53+
models: new Map(),
54+
}
55+
this.byProduct.set(product, productUsage)
56+
}
57+
58+
const { requests, aiuQuantity, grossAmount, aiuGrossAmount } = getUsageMetrics(record)
59+
productUsage.totals.requests += requests
60+
productUsage.totals.aiuQuantity += aiuQuantity
61+
productUsage.totals.grossAmount += grossAmount
62+
productUsage.totals.aiuGrossAmount += aiuGrossAmount
63+
64+
let modelTotals = productUsage.models.get(model)
65+
if (!modelTotals) {
66+
modelTotals = createTotals()
67+
productUsage.models.set(model, modelTotals)
68+
}
69+
70+
modelTotals.requests += requests
71+
modelTotals.aiuQuantity += aiuQuantity
72+
modelTotals.grossAmount += grossAmount
73+
modelTotals.aiuGrossAmount += aiuGrossAmount
74+
}
75+
76+
result(): ProductUsageResult {
77+
const products = Array.from(this.byProduct.entries())
78+
.map<ProductUsage>(([product, usage]) => ({
79+
product,
80+
totals: usage.totals,
81+
models: Object.fromEntries(
82+
Array.from(usage.models.entries()).sort((a, b) => b[1].grossAmount - a[1].grossAmount),
83+
),
84+
}))
85+
.sort((a, b) => b.totals.grossAmount - a.totals.grossAmount)
86+
87+
return { products }
88+
}
89+
}

src/views/CostCentersView.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ export function CostCentersView({ data, rangeStart }: { data: CostCenterResult;
133133
</div>
134134

135135
<div className="cost-centers-view__tables">
136-
<div className="cost-centers-view__tableWrap">
136+
<div className="cost-centers-view__tableWrap data-table-wrap">
137137
<div className="cost-centers-view__tableTitle">Per model</div>
138-
<table className="cost-centers-view__table">
138+
<table className="cost-centers-view__table data-table">
139139
<thead>
140140
<tr>
141141
<th>Model</th>
@@ -166,9 +166,9 @@ export function CostCentersView({ data, rangeStart }: { data: CostCenterResult;
166166
</table>
167167
</div>
168168

169-
<div className="cost-centers-view__tableWrap">
169+
<div className="cost-centers-view__tableWrap data-table-wrap">
170170
<div className="cost-centers-view__tableTitle">Per user</div>
171-
<table className="cost-centers-view__table">
171+
<table className="cost-centers-view__table data-table">
172172
<thead>
173173
<tr>
174174
<th>User</th>

src/views/OrganizationsView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export function OrganizationsView({ data }: { data: OrganizationResult }) {
2020
</div>
2121
</div>
2222

23-
<div className="orgs-view__tableWrap">
24-
<table className="orgs-view__table">
23+
<div className="orgs-view__tableWrap data-table-wrap">
24+
<table className="orgs-view__table data-table">
2525
<thead>
2626
<tr>
2727
<th>Organization</th>

0 commit comments

Comments
 (0)