diff --git a/src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.ts b/src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.ts index cbc0237..08d5ef7 100644 --- a/src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.ts +++ b/src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.ts @@ -30,6 +30,7 @@ export class ChartPieUserComponent implements OnChanges { }] }; updateFromInput: boolean = false; + hasUsernameData: boolean = false; constructor( private themeService: ThemingService, @@ -42,12 +43,19 @@ export class ChartPieUserComponent implements OnChanges { } ngOnChanges() { + this.hasUsernameData = this.usageReportService.hasUsernameData; + + // If no username data, show usage by repository instead + const groupByField = this.hasUsernameData ? 'username' : 'repositoryName'; + const chartTitle = this.hasUsernameData ? 'username' : 'repository'; + this.data = this.data.filter((line) => line.unitType === 'minutes'); const aggregatedData = this.data.reduce((acc, line) => { - const index = acc.findIndex((item) => item[0] === line.username); + const fieldValue = groupByField === 'username' ? (line.username || 'Unknown') : (line.repositoryName || 'Unknown'); + const index = acc.findIndex((item) => item[0] === fieldValue); if (index === -1) { - acc.push([line.username, line.value]); + acc.push([fieldValue, line.value]); } else { acc[index][1] += line.value; } @@ -72,7 +80,7 @@ export class ChartPieUserComponent implements OnChanges { data }]; this.options.title = { - text: `${this.currency === 'minutes' ? 'Usage' : 'Cost'} by username` + text: `${this.currency === 'minutes' ? 'Usage' : 'Cost'} by ${chartTitle}` }; this.options.tooltip = { ...this.options.tooltip, diff --git a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html index a01ffc2..bf110e3 100644 --- a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html +++ b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html @@ -5,8 +5,8 @@ Runner Repo - Workflow - User + Workflow + User diff --git a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts index 2a0aac2..6c4497c 100644 --- a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts +++ b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts @@ -61,15 +61,31 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { @Input() currency!: string; dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property tableType: 'workflow' | 'repo' | 'sku' | 'user' = 'sku'; + + // Track available grouping options based on CSV format + hasWorkflowData: boolean = false; + hasUsernameData: boolean = false; @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; constructor( - private usageReportService: UsageReportService, + public usageReportService: UsageReportService, ) { } ngOnChanges() { + // Update available grouping options based on the data + this.hasWorkflowData = this.usageReportService.hasWorkflowData; + this.hasUsernameData = this.usageReportService.hasUsernameData; + + // Reset table type if current selection is not available + if (this.tableType === 'workflow' && !this.hasWorkflowData) { + this.tableType = 'sku'; + } + if (this.tableType === 'user' && !this.hasUsernameData) { + this.tableType = 'sku'; + } + this.initializeColumns(); let usage: WorkflowUsageItem[] | RepoUsageItem[] | SkuUsageItem[] = []; let usageItems: WorkflowUsageItem[] = (usage as WorkflowUsageItem[]); @@ -82,7 +98,7 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { } else if (this.tableType === 'sku') { return a.sku === this.usageReportService.formatSku(line.sku); } else if (this.tableType === 'user') { - return a.username === line.username; + return a.username === (line.username || 'Unknown'); } return false }); @@ -130,7 +146,7 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { } else { acc.push({ workflow: line.workflowName || line.workflowPath || 'Unknown Workflow', - repo: line.repositoryName, + repo: line.repositoryName || 'Unknown Repository', total: line.quantity, cost: line.quantity * line.pricePerUnit, runs: 1, @@ -139,7 +155,7 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { avgTime: line.value, [month]: line.value, sku: this.usageReportService.formatSku(line.sku), - username: line.username, + username: line.username || 'Unknown', }); } return acc; diff --git a/src/app/components/usage/usage.component.html b/src/app/components/usage/usage.component.html index 285a1fd..f39ae48 100644 --- a/src/app/components/usage/usage.component.html +++ b/src/app/components/usage/usage.component.html @@ -31,7 +31,7 @@

Premium Requests

close - + 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; }