+
Workflow
@@ -46,6 +46,12 @@ Premium Requests
}
+
+ info
+ Summarized report
+
diff --git a/src/app/components/usage/usage.component.scss b/src/app/components/usage/usage.component.scss
index d6267cc..bac5bcd 100644
--- a/src/app/components/usage/usage.component.scss
+++ b/src/app/components/usage/usage.component.scss
@@ -21,4 +21,19 @@ form {
.tab-icon {
margin-right: 8px;
+}
+
+.format-notice {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ opacity: 0.7;
+ cursor: help;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ }
}
\ No newline at end of file
diff --git a/src/app/components/usage/usage.component.ts b/src/app/components/usage/usage.component.ts
index 88e9ac0..82374e5 100644
--- a/src/app/components/usage/usage.component.ts
+++ b/src/app/components/usage/usage.component.ts
@@ -1,8 +1,7 @@
import { OnInit, ChangeDetectorRef, Component, OnDestroy, isDevMode } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
-import { UsageReport } from 'github-usage-report/src/types';
import { Observable, Subscription, debounceTime, map, startWith } from 'rxjs';
-import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service';
+import { CustomUsageReportLine, UsageReport, UsageReportService } from 'src/app/usage-report.service';
import { DialogBillingNavigateComponent } from './dialog-billing-navigate';
import { MatDialog } from '@angular/material/dialog';
import { ModelUsageReport } from 'github-usage-report';
@@ -38,6 +37,8 @@ export class UsageComponent implements OnInit, OnDestroy {
subscriptions: Subscription[] = [];
currency: 'minutes' | 'cost' = 'cost';
tabSelected: 'shared-storage' | 'copilot' | 'actions' = 'actions';
+ hasWorkflowData: boolean = false;
+ formatType: 'legacy' | 'summarized' | null = null;
constructor(
private usageReportService: UsageReportService,
@@ -107,6 +108,10 @@ export class UsageComponent implements OnInit, OnDestroy {
this.usageReportService.getWorkflowsFiltered().subscribe((workflows) => {
this.workflows = workflows;
}),
+ this.usageReportService.formatType.subscribe((formatType) => {
+ this.formatType = formatType;
+ this.hasWorkflowData = this.usageReportService.hasWorkflowData;
+ }),
);
}
diff --git a/src/app/usage-report.service.ts b/src/app/usage-report.service.ts
index 4d8fa94..497352d 100644
--- a/src/app/usage-report.service.ts
+++ b/src/app/usage-report.service.ts
@@ -1,6 +1,6 @@
import { TitleCasePipe } from '@angular/common';
import { Injectable } from '@angular/core';
-import { ModelUsageReport, readGithubUsageReport, readModelUsageReport, UsageReport, UsageReportLine } from 'github-usage-report';
+import { ModelUsageReport, readModelUsageReport } from 'github-usage-report';
import { BehaviorSubject, Observable, map } from 'rxjs';
interface Filter {
@@ -12,6 +12,33 @@ interface Filter {
type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces';
+// Custom types to support both old and new CSV formats
+export interface UsageReportLine {
+ date: Date;
+ product: string;
+ sku: string;
+ quantity: number;
+ unitType: string;
+ pricePerUnit: number;
+ grossAmount: number;
+ discountAmount: number;
+ netAmount: number;
+ username: string; // Empty string for new format
+ organization: string;
+ repositoryName: string;
+ workflowName?: string; // Optional - not present in new format
+ workflowPath?: string; // Optional - not present in new format
+ costCenterName: string;
+}
+
+export interface UsageReport {
+ days: number;
+ startDate: Date;
+ endDate: Date;
+ lines: UsageReportLine[];
+ formatType: 'legacy' | 'summarized'; // Track which format was used (legacy-15/14 → 'legacy', summarized-12 → 'summarized')
+}
+
export interface CustomUsageReportLine extends UsageReportLine {
value: number;
}
@@ -20,6 +47,191 @@ export interface CustomUsageReport extends UsageReport {
lines: CustomUsageReportLine[];
}
+type CsvFormat = 'legacy-15' | 'legacy-14' | 'summarized-12';
+
+/**
+ * Detects the CSV format based on header columns and column count
+ *
+ * Supported formats:
+ * - legacy-15: usage_at, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, username, organization,
+ * repository_name, workflow_name, workflow_path, cost_center_name
+ * - legacy-14: date, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, username, organization,
+ * repository, workflow_path, cost_center_name
+ * - summarized-12: date, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, organization, repository, cost_center_name
+ */
+function detectCsvFormat(headerLine: string): CsvFormat {
+ const headers = headerLine.toLowerCase();
+ const columnCount = headerLine.split(',').length;
+
+ // Check for legacy format indicators
+ if (headers.includes('usage_at') || headers.includes('workflow_name')) {
+ return 'legacy-15';
+ }
+
+ // Check column count and presence of username/workflow_path
+ if (columnCount >= 14 && (headers.includes('username') || headers.includes('workflow_path'))) {
+ return 'legacy-14';
+ }
+
+ // New summarized format - 12 columns, no username or workflow data
+ return 'summarized-12';
+}
+
+/**
+ * Parse a single line from the legacy-15 format (15 columns)
+ * Columns: usage_at, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, username, organization,
+ * repository_name, workflow_name, workflow_path, cost_center_name
+ */
+function parseLegacy15Line(csv: string[]): UsageReportLine {
+ return {
+ date: new Date(Date.parse(csv[0])),
+ product: csv[1],
+ sku: csv[2],
+ quantity: Number(csv[3]),
+ unitType: csv[4],
+ pricePerUnit: Number(csv[5]),
+ grossAmount: Number(csv[6]),
+ discountAmount: Number(csv[7]),
+ netAmount: Number(csv[8]),
+ username: csv[9] || '',
+ organization: csv[10] || '',
+ repositoryName: csv[11] || '',
+ workflowName: csv[12] || undefined,
+ workflowPath: csv[13] || undefined,
+ costCenterName: csv[14] || '',
+ };
+}
+
+/**
+ * Parse a single line from the legacy-14 format (14 columns)
+ * Columns: date, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, username, organization,
+ * repository, workflow_path, cost_center_name
+ */
+function parseLegacy14Line(csv: string[]): UsageReportLine {
+ return {
+ date: new Date(Date.parse(csv[0])),
+ product: csv[1],
+ sku: csv[2],
+ quantity: Number(csv[3]),
+ unitType: csv[4],
+ pricePerUnit: Number(csv[5]),
+ grossAmount: Number(csv[6]),
+ discountAmount: Number(csv[7]),
+ netAmount: Number(csv[8]),
+ username: csv[9] || '',
+ organization: csv[10] || '',
+ repositoryName: csv[11] || '',
+ workflowName: undefined,
+ workflowPath: csv[12] || undefined,
+ costCenterName: csv[13] || '',
+ };
+}
+
+/**
+ * Parse a single line from the new summarized format (12 columns)
+ * Columns: date, product, sku, quantity, unit_type, applied_cost_per_quantity,
+ * gross_amount, discount_amount, net_amount, organization, repository, cost_center_name
+ */
+function parseSummarizedLine(csv: string[]): UsageReportLine {
+ return {
+ date: new Date(Date.parse(csv[0])),
+ product: csv[1],
+ sku: csv[2],
+ quantity: Number(csv[3]),
+ unitType: csv[4],
+ pricePerUnit: Number(csv[5]),
+ grossAmount: Number(csv[6]),
+ discountAmount: Number(csv[7]),
+ netAmount: Number(csv[8]),
+ username: '', // Not available in new format
+ organization: csv[9] || '',
+ repositoryName: csv[10] || '',
+ workflowName: undefined, // Not available in new format
+ workflowPath: undefined, // Not available in new format
+ costCenterName: csv[11] || '',
+ };
+}
+
+/**
+ * Custom CSV parser that supports both old and new GitHub usage report formats
+ */
+async function readGithubUsageReport(data: string): Promise {
+ return new Promise((resolve, reject) => {
+ const usageReportLines: UsageReportLine[] = [];
+ const lines = data.split(/\r?\n/);
+
+ if (lines.length < 2) {
+ reject(new Error('CSV file is empty or has no data rows'));
+ return;
+ }
+
+ const formatType = detectCsvFormat(lines[0]);
+ console.log(`Detected CSV format: ${formatType}`);
+
+ lines.forEach((line, index) => {
+ if (index === 0 || line.trim().length === 0) return;
+
+ try {
+ const csv = line.split(',').map(field => field.replace(/^"|"$/g, ''));
+
+ let parsedLine: UsageReportLine;
+
+ if (formatType === 'legacy-15') {
+ if (csv.length !== 15) {
+ console.warn(`Skipping line ${index + 1}: expected 15 columns for legacy-15 format, got ${csv.length}`);
+ return;
+ }
+ parsedLine = parseLegacy15Line(csv);
+ } else if (formatType === 'legacy-14') {
+ if (csv.length !== 14) {
+ console.warn(`Skipping line ${index + 1}: expected 14 columns for legacy-14 format, got ${csv.length}`);
+ return;
+ }
+ parsedLine = parseLegacy14Line(csv);
+ } else {
+ // New summarized format: 12 columns
+ if (csv.length !== 12) {
+ console.warn(`Skipping line ${index + 1}: expected 12 columns for summarized format, got ${csv.length}`);
+ return;
+ }
+ parsedLine = parseSummarizedLine(csv);
+ }
+
+ usageReportLines.push(parsedLine);
+ } catch (err) {
+ console.warn(`Skipping line ${index + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ }
+ });
+
+ if (usageReportLines.length === 0) {
+ reject(new Error('No valid data rows found in CSV file'));
+ return;
+ }
+
+ // Sort by date to ensure correct date range
+ usageReportLines.sort((a, b) => a.date.getTime() - b.date.getTime());
+
+ const startDate = usageReportLines[0].date;
+ const endDate = usageReportLines[usageReportLines.length - 1].date;
+
+ // Map detailed format type to simplified version for UI
+ const simplifiedFormatType: 'legacy' | 'summarized' = formatType === 'summarized-12' ? 'summarized' : 'legacy';
+
+ resolve({
+ startDate,
+ endDate,
+ days: (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
+ lines: usageReportLines,
+ formatType: simplifiedFormatType,
+ });
+ });
+}
+
@Injectable({
providedIn: 'root'
})
@@ -43,7 +255,10 @@ export class UsageReportService {
skus: string[] = [];
products: string[] = [];
usernames: string[] = [];
- valueType: BehaviorSubject<'minutes' | 'cost'> = new BehaviorSubject<'minutes' | 'cost'>('cost')
+ valueType: BehaviorSubject<'minutes' | 'cost'> = new BehaviorSubject<'minutes' | 'cost'>('cost');
+ formatType: BehaviorSubject<'legacy' | 'summarized' | null> = new BehaviorSubject<'legacy' | 'summarized' | null>(null);
+ hasWorkflowData: boolean = false;
+ hasUsernameData: boolean = false;
skuMapping: { [key: string]: string } = {
"actions_linux": 'Ubuntu 2',
"actions_linux_16_core": 'Ubuntu 16',
@@ -162,6 +377,12 @@ export class UsageReportService {
this.skus = [];
this.products = [];
this.usernames = [];
+
+ // Track format type and available data
+ this.formatType.next(this.usageReport.formatType);
+ this.hasWorkflowData = false;
+ this.hasUsernameData = false;
+
this.usageReport.lines.forEach(line => {
if (!this.owners.includes(line.organization)) {
this.owners.push(line.organization);
@@ -172,6 +393,7 @@ export class UsageReportService {
const workflow = line.workflowName || line.workflowPath;
if (workflow && !this.workflows.includes(workflow)) {
this.workflows.push(workflow);
+ this.hasWorkflowData = true;
}
if (!this.skus.includes(line.sku)) {
this.skus.push(line.sku);
@@ -179,12 +401,14 @@ export class UsageReportService {
if (!this.products.includes(line.product)) {
this.products.push(line.product);
}
- if (!this.usernames.includes(line.username)) {
+ if (line.username && !this.usernames.includes(line.username)) {
this.usernames.push(line.username);
+ this.hasUsernameData = true;
}
});
this.setValueType(this.valueType.value);
console.log('Usage Report Loaded:', this.usageReport);
+ console.log(`Format: ${this.usageReport.formatType}, Has Workflow Data: ${this.hasWorkflowData}, Has Username Data: ${this.hasUsernameData}`);
return this.usageReport;
}