├── .editorconfig ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src ├── _theme-colors.scss ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── components │ │ └── usage │ │ │ ├── actions │ │ │ ├── actions.component.html │ │ │ ├── actions.component.scss │ │ │ ├── actions.component.spec.ts │ │ │ ├── actions.component.ts │ │ │ ├── charts │ │ │ │ ├── chart-bar-top-time │ │ │ │ │ ├── chart-bar-top-time.component.html │ │ │ │ │ ├── chart-bar-top-time.component.scss │ │ │ │ │ ├── chart-bar-top-time.component.spec.ts │ │ │ │ │ └── chart-bar-top-time.component.ts │ │ │ │ ├── chart-line-usage-daily │ │ │ │ │ ├── chart-line-usage-daily.component.html │ │ │ │ │ ├── chart-line-usage-daily.component.scss │ │ │ │ │ ├── chart-line-usage-daily.component.spec.ts │ │ │ │ │ └── chart-line-usage-daily.component.ts │ │ │ │ ├── chart-pie-sku │ │ │ │ │ ├── chart-pie-sku.component.html │ │ │ │ │ ├── chart-pie-sku.component.scss │ │ │ │ │ ├── chart-pie-sku.component.spec.ts │ │ │ │ │ └── chart-pie-sku.component.ts │ │ │ │ └── chart-pie-user │ │ │ │ │ ├── chart-pie-user.component.html │ │ │ │ │ ├── chart-pie-user.component.scss │ │ │ │ │ ├── chart-pie-user.component.spec.ts │ │ │ │ │ └── chart-pie-user.component.ts │ │ │ └── table-workflow-usage │ │ │ │ ├── table-workflow-usage.component.html │ │ │ │ ├── table-workflow-usage.component.scss │ │ │ │ ├── table-workflow-usage.component.spec.ts │ │ │ │ └── table-workflow-usage.component.ts │ │ │ ├── codespaces │ │ │ ├── codespaces.component.html │ │ │ ├── codespaces.component.scss │ │ │ ├── codespaces.component.spec.ts │ │ │ ├── codespaces.component.ts │ │ │ └── table-codespaces-usage │ │ │ │ ├── table-codespaces-usage.component.html │ │ │ │ ├── table-codespaces-usage.component.scss │ │ │ │ ├── table-codespaces-usage.component.spec.ts │ │ │ │ └── table-codespaces-usage.component.ts │ │ │ ├── copilot │ │ │ ├── copilot.component.html │ │ │ ├── copilot.component.scss │ │ │ ├── copilot.component.spec.ts │ │ │ ├── copilot.component.ts │ │ │ └── table-workflow-usage │ │ │ │ ├── table-copilot-usage.component.html │ │ │ │ ├── table-copilot-usage.component.scss │ │ │ │ ├── table-copilot-usage.component.spec.ts │ │ │ │ └── table-copilot-usage.component.ts │ │ │ ├── dialog-billing-navigate.html │ │ │ ├── dialog-billing-navigate.ts │ │ │ ├── file-upload │ │ │ ├── file-upload.component.html │ │ │ ├── file-upload.component.scss │ │ │ ├── file-upload.component.spec.ts │ │ │ └── file-upload.component.ts │ │ │ ├── shared-storage │ │ │ ├── charts │ │ │ │ └── line-usage-time │ │ │ │ │ ├── line-usage-time.component.html │ │ │ │ │ ├── line-usage-time.component.scss │ │ │ │ │ ├── line-usage-time.component.spec.ts │ │ │ │ │ └── line-usage-time.component.ts │ │ │ ├── shared-storage.component.html │ │ │ ├── shared-storage.component.scss │ │ │ ├── shared-storage.component.spec.ts │ │ │ ├── shared-storage.component.ts │ │ │ └── table-shared-storage │ │ │ │ ├── table-shared-storage.component.html │ │ │ │ ├── table-shared-storage.component.scss │ │ │ │ ├── table-shared-storage.component.spec.ts │ │ │ │ └── table-shared-storage.component.ts │ │ │ ├── usage.component.html │ │ │ ├── usage.component.scss │ │ │ ├── usage.component.spec.ts │ │ │ └── usage.component.ts │ ├── theme.service.spec.ts │ ├── theme.service.ts │ ├── usage-report.service.spec.ts │ └── usage-report.service.ts ├── assets │ ├── .gitkeep │ ├── chrome_5PUHU4YqD9.png │ ├── chrome_ZrMnUYgg9f.png │ ├── chrome_htTVyAJEel.png │ ├── github-copilot.svg │ ├── github-mark-green.svg │ ├── github-mark-white.svg │ ├── github-mark.svg │ ├── github-usage-report.csv │ └── screencapture-austenstone-github-io-github-actions-usage-report-2024-02-06-10_46_53.png ├── favicon.ico ├── highcharts.github.theme.ts ├── highcharts.theme.ts ├── index.html ├── main.ts ├── material.module.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@angular-eslint/recommended", 15 | "plugin:@angular-eslint/template/process-inline-templates" 16 | ], 17 | "rules": { 18 | "@angular-eslint/directive-selector": [ 19 | "error", 20 | { 21 | "type": "attribute", 22 | "prefix": "app", 23 | "style": "camelCase" 24 | } 25 | ], 26 | "@angular-eslint/component-selector": [ 27 | "error", 28 | { 29 | "type": "element", 30 | "prefix": "app", 31 | "style": "kebab-case" 32 | } 33 | ], 34 | "@typescript-eslint/no-explicit-any": "off" 35 | } 36 | }, 37 | { 38 | "files": [ 39 | "*.html" 40 | ], 41 | "extends": [ 42 | "plugin:@angular-eslint/template/recommended", 43 | "plugin:@angular-eslint/template/accessibility" 44 | ], 45 | "rules": {} 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: npm 8 | directory: / 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | pull_request: 6 | branches: [ "main" ] 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | cache: 'npm' 19 | - run: npm ci 20 | - run: npm run build -- --configuration production --base-href /${{ github.event.repository.name }}/ 21 | # - run: npm test -- --no-watch --no-progress --browsers=ChromeHeadless 22 | - uses: actions/upload-artifact@v4 23 | with: 24 | name: dist 25 | path: dist/${{ github.event.repository.name }}/browser 26 | 27 | deploy: 28 | needs: build 29 | environment: 30 | name: github-pages 31 | url: ${{ steps.deployment.outputs.page_url }} 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: read 35 | pages: write 36 | id-token: write 37 | steps: 38 | - name: Download artifact 39 | uses: actions/download-artifact@v4 40 | with: 41 | name: dist 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v5 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: '.' 48 | - name: 404 GitHub Pages 49 | run: cp index.html 404.html 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "pwa-chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Austen Stone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [GitHub Usage Report Viewer](https://austenstone.github.io/github-actions-usage-report/) 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.2.6. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "github-actions-usage-report": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": { 21 | "base": "dist/github-actions-usage-report" 22 | }, 23 | "index": "src/index.html", 24 | "polyfills": [ 25 | "zone.js" 26 | ], 27 | "tsConfig": "tsconfig.app.json", 28 | "inlineStyleLanguage": "scss", 29 | "assets": [ 30 | "src/favicon.ico", 31 | "src/assets" 32 | ], 33 | "styles": [ 34 | "src/styles.scss" 35 | ], 36 | "scripts": [], 37 | "allowedCommonJsDependencies": [ 38 | "highcharts" 39 | ], 40 | "browser": "src/main.ts" 41 | }, 42 | "configurations": { 43 | "production": { 44 | "budgets": [ 45 | { 46 | "type": "initial", 47 | "maximumWarning": "2mb", 48 | "maximumError": "5mb" 49 | }, 50 | { 51 | "type": "anyComponentStyle", 52 | "maximumWarning": "2kb", 53 | "maximumError": "4kb" 54 | } 55 | ], 56 | "outputHashing": "all" 57 | }, 58 | "development": { 59 | "optimization": false, 60 | "extractLicenses": false, 61 | "sourceMap": true, 62 | "namedChunks": true 63 | } 64 | }, 65 | "defaultConfiguration": "production" 66 | }, 67 | "serve": { 68 | "builder": "@angular-devkit/build-angular:dev-server", 69 | "configurations": { 70 | "production": { 71 | "buildTarget": "github-actions-usage-report:build:production" 72 | }, 73 | "development": { 74 | "buildTarget": "github-actions-usage-report:build:development" 75 | } 76 | }, 77 | "defaultConfiguration": "development" 78 | }, 79 | "extract-i18n": { 80 | "builder": "@angular-devkit/build-angular:extract-i18n", 81 | "options": { 82 | "buildTarget": "github-actions-usage-report:build" 83 | } 84 | }, 85 | "test": { 86 | "builder": "@angular-devkit/build-angular:karma", 87 | "options": { 88 | "polyfills": [ 89 | "zone.js", 90 | "zone.js/testing" 91 | ], 92 | "tsConfig": "tsconfig.spec.json", 93 | "inlineStyleLanguage": "scss", 94 | "assets": [ 95 | "src/favicon.ico", 96 | "src/assets" 97 | ], 98 | "styles": [ 99 | "src/styles.scss" 100 | ], 101 | "scripts": [] 102 | } 103 | }, 104 | "lint": { 105 | "builder": "@angular-eslint/builder:lint", 106 | "options": { 107 | "lintFilePatterns": [ 108 | "src/**/*.ts", 109 | "src/**/*.html" 110 | ] 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "cli": { 117 | "analytics": "2c80f2c8-df0d-44ed-a1c5-b44b03d6bda4", 118 | "schematicCollections": [ 119 | "@angular-eslint/schematics" 120 | ] 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-actions-usage-report", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^19.2.14", 15 | "@angular/cdk": "^19.2.18", 16 | "@angular/common": "^19.2.14", 17 | "@angular/compiler": "^19.2.14", 18 | "@angular/core": "^19.2.14", 19 | "@angular/forms": "^19.2.14", 20 | "@angular/material": "^19.2.18", 21 | "@angular/platform-browser": "^19.2.14", 22 | "@angular/platform-browser-dynamic": "^19.2.14", 23 | "@angular/router": "^19.2.14", 24 | "github-usage-report": "3.1", 25 | "highcharts": "^11.2.0", 26 | "highcharts-angular": "^4.0.0", 27 | "rxjs": "~7.8.0", 28 | "tslib": "^2.3.0", 29 | "zone.js": "~0.15.1" 30 | }, 31 | "devDependencies": { 32 | "@angular-devkit/build-angular": "^19.2.14", 33 | "@angular-eslint/builder": "19.7.1", 34 | "@angular-eslint/eslint-plugin": "19.7.1", 35 | "@angular-eslint/eslint-plugin-template": "19.7.1", 36 | "@angular-eslint/schematics": "19.7.1", 37 | "@angular-eslint/template-parser": "19.7.1", 38 | "@angular/cli": "~19.2.14", 39 | "@angular/compiler-cli": "^19.2.14", 40 | "@types/jasmine": "~4.3.0", 41 | "@typescript-eslint/eslint-plugin": "^7.2.0", 42 | "@typescript-eslint/parser": "^7.2.0", 43 | "eslint": "^8.57.0", 44 | "jasmine-core": "~4.5.0", 45 | "karma": "~6.4.0", 46 | "karma-chrome-launcher": "~3.1.0", 47 | "karma-coverage": "~2.2.0", 48 | "karma-jasmine": "~5.1.0", 49 | "karma-jasmine-html-reporter": "~2.0.0", 50 | "typescript": "~5.5.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/_theme-colors.scss: -------------------------------------------------------------------------------- 1 | // This file was generated by running 'ng generate @angular/material:theme-color'. 2 | // Proceed with caution if making changes to this file. 3 | 4 | @use 'sass:map'; 5 | @use '@angular/material' as mat; 6 | 7 | // Note: Color palettes are generated from primary: #1f6feb 8 | $_palettes: ( 9 | primary: ( 10 | 0: #000000, 11 | 10: #001944, 12 | 20: #002d6d, 13 | 25: #003883, 14 | 30: #004299, 15 | 35: #004db0, 16 | 40: #0059c8, 17 | 50: #2471ed, 18 | 60: #538dff, 19 | 70: #84aaff, 20 | 80: #afc6ff, 21 | 90: #d9e2ff, 22 | 95: #edf0ff, 23 | 98: #faf8ff, 24 | 99: #fefbff, 25 | 100: #ffffff, 26 | ), 27 | secondary: ( 28 | 0: #000000, 29 | 10: #001944, 30 | 20: #162f5e, 31 | 25: #223a6a, 32 | 30: #2f4676, 33 | 35: #3b5182, 34 | 40: #475d8f, 35 | 50: #6076aa, 36 | 60: #7a90c5, 37 | 70: #94abe1, 38 | 80: #afc6fe, 39 | 90: #d9e2ff, 40 | 95: #edf0ff, 41 | 98: #faf8ff, 42 | 99: #fefbff, 43 | 100: #ffffff, 44 | ), 45 | tertiary: ( 46 | 0: #000000, 47 | 10: #330044, 48 | 20: #54006d, 49 | 25: #650083, 50 | 30: #741392, 51 | 35: #81259f, 52 | 40: #8f34ac, 53 | 50: #ab4fc8, 54 | 60: #c76ae4, 55 | 70: #e486ff, 56 | 80: #f0b0ff, 57 | 90: #fbd7ff, 58 | 95: #ffebff, 59 | 98: #fff7fb, 60 | 99: #fffbff, 61 | 100: #ffffff, 62 | ), 63 | neutral: ( 64 | 0: #000000, 65 | 10: #191b23, 66 | 20: #2e3038, 67 | 25: #393b43, 68 | 30: #44464f, 69 | 35: #50525b, 70 | 40: #5c5e67, 71 | 50: #757780, 72 | 60: #8e909a, 73 | 70: #a9abb5, 74 | 80: #c5c6d0, 75 | 90: #e1e2ec, 76 | 95: #eff0fb, 77 | 98: #faf8ff, 78 | 99: #fefbff, 79 | 100: #ffffff, 80 | 4: #0b0e15, 81 | 6: #10131a, 82 | 12: #1d1f27, 83 | 17: #272a32, 84 | 22: #32353d, 85 | 24: #363941, 86 | 87: #d8d9e4, 87 | 92: #e6e7f2, 88 | 94: #ecedf8, 89 | 96: #f2f3fe, 90 | ), 91 | neutral-variant: ( 92 | 0: #000000, 93 | 10: #171b27, 94 | 20: #2b303d, 95 | 25: #363b48, 96 | 30: #424754, 97 | 35: #4d5260, 98 | 40: #595e6c, 99 | 50: #727786, 100 | 60: #8c90a0, 101 | 70: #a6abbb, 102 | 80: #c2c6d6, 103 | 90: #dee2f3, 104 | 95: #edf0ff, 105 | 98: #faf8ff, 106 | 99: #fefbff, 107 | 100: #ffffff, 108 | ), 109 | error: ( 110 | 0: #000000, 111 | 10: #410002, 112 | 20: #690005, 113 | 25: #7e0007, 114 | 30: #93000a, 115 | 35: #a80710, 116 | 40: #ba1a1a, 117 | 50: #de3730, 118 | 60: #ff5449, 119 | 70: #ff897d, 120 | 80: #ffb4ab, 121 | 90: #ffdad6, 122 | 95: #ffedea, 123 | 98: #fff8f7, 124 | 99: #fffbff, 125 | 100: #ffffff, 126 | ), 127 | ); 128 | 129 | $_rest: ( 130 | secondary: map.get($_palettes, secondary), 131 | neutral: map.get($_palettes, neutral), 132 | neutral-variant: map.get($_palettes, neutral-variant), 133 | error: map.get($_palettes, error), 134 | ); 135 | 136 | $primary-palette: map.merge(map.get($_palettes, primary), $_rest); 137 | $tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest); -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 10 | 11 |
-------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/app.component.scss -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | import { UsageComponent } from './components/usage/usage.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | declarations: [ 9 | AppComponent, 10 | UsageComponent 11 | ], 12 | }).compileComponents(); 13 | }); 14 | 15 | it('should create the app', () => { 16 | const fixture = TestBed.createComponent(AppComponent); 17 | const app = fixture.componentInstance; 18 | expect(app).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import ExportingModule from 'highcharts/modules/exporting'; 3 | import * as Highcharts from "highcharts"; 4 | import { ThemingService } from './theme.service'; 5 | import { MatIconRegistry } from '@angular/material/icon'; 6 | import { DomSanitizer } from '@angular/platform-browser'; 7 | import { UsageReportService } from './usage-report.service'; 8 | ExportingModule(Highcharts); 9 | import '../highcharts.github.theme'; 10 | 11 | const GITHUB_ICON = ` 12 | 13 | `; 14 | const COPILOT_ICON = ` 15 | 16 | 17 | `; 18 | 19 | @Component({ 20 | selector: 'app-root', 21 | templateUrl: './app.component.html', 22 | styleUrls: ['./app.component.scss'], 23 | standalone: false 24 | }) 25 | export class AppComponent implements OnInit { 26 | theme!: 'light-theme' | 'dark-theme'; 27 | options: Highcharts.Options = { 28 | credits: { 29 | enabled: false 30 | }, 31 | }; 32 | constructor( 33 | private themeService: ThemingService, 34 | iconRegistry: MatIconRegistry, 35 | sanitizer: DomSanitizer, 36 | private usageReportService: UsageReportService, 37 | ) { 38 | iconRegistry.addSvgIconLiteral('github', sanitizer.bypassSecurityTrustHtml(GITHUB_ICON)); 39 | iconRegistry.addSvgIconLiteral('copilot', sanitizer.bypassSecurityTrustHtml(COPILOT_ICON)); 40 | } 41 | 42 | ngOnInit() { 43 | this.options = { 44 | colors: this.themeService.getColors(), 45 | lang: { 46 | thousandsSep: ',' 47 | } 48 | }; 49 | this.themeService.getTheme().subscribe(theme => { 50 | this.theme = theme; 51 | if (theme === 'dark-theme') { 52 | this.options = { 53 | ...this.options, 54 | chart: { 55 | backgroundColor: 'transparent', 56 | style: { 57 | fontFamily: '\'Noto Sans\', sans-serif' 58 | }, 59 | plotBorderColor: '#606063' 60 | }, 61 | title: { 62 | style: { 63 | color: '#ffffff', 64 | textTransform: 'uppercase', 65 | fontSize: '24px', 66 | fontWeight: '400' 67 | } 68 | }, 69 | subtitle: { 70 | style: { 71 | color: '#E0E0E3', 72 | textTransform: 'uppercase' 73 | } 74 | }, 75 | xAxis: { 76 | gridLineColor: '#707073', 77 | labels: { 78 | style: { 79 | color: '#E0E0E3' 80 | } 81 | }, 82 | lineColor: '#707073', 83 | minorGridLineColor: '#505053', 84 | tickColor: '#707073', 85 | title: { 86 | style: { 87 | color: '#A0A0A3' 88 | 89 | } 90 | } 91 | }, 92 | yAxis: { 93 | gridLineColor: '#707073', 94 | labels: { 95 | style: { 96 | color: '#E0E0E3' 97 | } 98 | }, 99 | lineColor: '#707073', 100 | minorGridLineColor: '#505053', 101 | tickColor: '#707073', 102 | tickWidth: 1, 103 | title: { 104 | style: { 105 | color: '#A0A0A3' 106 | } 107 | } 108 | }, 109 | tooltip: { 110 | backgroundColor: 'rgba(0, 0, 0, 0.85)', 111 | style: { 112 | color: '#F0F0F0' 113 | }, 114 | pointFormat: '{point.y:.2f}' 115 | }, 116 | plotOptions: { 117 | series: { 118 | dataLabels: { 119 | color: '#F0F0F3', 120 | style: { 121 | fontSize: '13px' 122 | } 123 | }, 124 | marker: { 125 | lineColor: '#333' 126 | } 127 | }, 128 | boxplot: { 129 | fillColor: '#505053' 130 | }, 131 | candlestick: { 132 | lineColor: 'white' 133 | }, 134 | errorbar: { 135 | color: 'white' 136 | }, 137 | bar: { 138 | borderColor: '#424242' 139 | }, 140 | pie: { 141 | borderColor: '#424242' 142 | }, 143 | }, 144 | legend: { 145 | backgroundColor: 'transparent', 146 | itemStyle: { 147 | color: '#E0E0E3' 148 | }, 149 | itemHoverStyle: { 150 | color: '#FFF' 151 | }, 152 | itemHiddenStyle: { 153 | color: '#606063' 154 | }, 155 | title: { 156 | style: { 157 | color: '#C0C0C0' 158 | } 159 | } 160 | }, 161 | drilldown: { 162 | activeAxisLabelStyle: { 163 | color: '#F0F0F3' 164 | }, 165 | activeDataLabelStyle: { 166 | color: '#F0F0F3' 167 | } 168 | }, 169 | navigation: { 170 | buttonOptions: { 171 | // symbolStroke: '#DDDDDD', 172 | theme: { 173 | fill: 'transparent' 174 | } 175 | }, 176 | menuStyle: { 177 | background: '#4a4a4a', 178 | color: '#E0E0E3' 179 | }, 180 | menuItemStyle: { 181 | fontWeight: 'normal', 182 | background: '#4a4a4a', 183 | color: '#E0E0E3' 184 | }, 185 | menuItemHoverStyle: { 186 | fontWeight: 'bold', 187 | background: '#4a4a4a', 188 | color: 'white' 189 | } 190 | }, 191 | rangeSelector: { 192 | buttonTheme: { 193 | fill: '#505053', 194 | stroke: '#000000', 195 | style: { 196 | color: '#CCC' 197 | }, 198 | states: { 199 | hover: { 200 | fill: '#707073', 201 | stroke: '#000000', 202 | style: { 203 | color: 'white' 204 | } 205 | }, 206 | select: { 207 | fill: '#000003', 208 | stroke: '#000000', 209 | style: { 210 | color: 'white' 211 | } 212 | } 213 | } 214 | }, 215 | inputBoxBorderColor: '#505053', 216 | inputStyle: { 217 | backgroundColor: '#333', 218 | color: 'silver' 219 | }, 220 | labelStyle: { 221 | color: 'silver' 222 | } 223 | }, 224 | navigator: { 225 | handles: { 226 | backgroundColor: '#666', 227 | borderColor: '#AAA' 228 | }, 229 | outlineColor: '#CCC', 230 | maskFill: 'rgba(255,255,255,0.1)', 231 | series: { 232 | color: '#7798BF', 233 | lineColor: '#A6C7ED' 234 | }, 235 | xAxis: { 236 | gridLineColor: '#505053' 237 | } 238 | }, 239 | scrollbar: { 240 | barBackgroundColor: '#808083', 241 | barBorderColor: '#808083', 242 | buttonArrowColor: '#CCC', 243 | buttonBackgroundColor: '#606063', 244 | buttonBorderColor: '#606063', 245 | rifleColor: '#FFF', 246 | trackBackgroundColor: '#404043', 247 | trackBorderColor: '#404043' 248 | } 249 | }; 250 | } 251 | // Highcharts.setOptions(this.options); 252 | } 253 | ) 254 | this.usageReportService.getValueType().subscribe(valueType => { 255 | this.options = { 256 | ...this.options, 257 | tooltip: { 258 | ...this.options.tooltip, 259 | pointFormat: `{series.name}
${valueType === 'cost' ? '${point.y:.2f}' : '{point.y} mins'}`, 260 | } 261 | } 262 | // Highcharts.setOptions(this.options); 263 | }); 264 | } 265 | } -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { MaterialModule } from '../material.module'; 4 | 5 | import { AppComponent } from './app.component'; 6 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 7 | import { UsageComponent } from './components/usage/usage.component'; 8 | import { FileUploadComponent } from './components/usage/file-upload/file-upload.component'; 9 | import { ChartPieUserComponent } from './components/usage/actions/charts/chart-pie-user/chart-pie-user.component'; 10 | import { TableWorkflowUsageComponent } from './components/usage/actions/table-workflow-usage/table-workflow-usage.component'; 11 | 12 | import { HighchartsChartModule } from 'highcharts-angular'; 13 | import { ChartBarTopTimeComponent } from './components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component'; 14 | import { ChartLineUsageDailyComponent } from './components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component'; 15 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 16 | import { ChartPieSkuComponent } from './components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component'; 17 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 18 | import { ActionsComponent } from './components/usage/actions/actions.component'; 19 | import { SharedStorageComponent } from './components/usage/shared-storage/shared-storage.component'; 20 | import { TableSharedStorageComponent } from './components/usage/shared-storage/table-shared-storage/table-shared-storage.component'; 21 | import { LineUsageTimeComponent } from './components/usage/shared-storage/charts/line-usage-time/line-usage-time.component'; 22 | import { CopilotComponent } from './components/usage/copilot/copilot.component'; 23 | import { DialogBillingNavigateComponent } from './components/usage/dialog-billing-navigate'; 24 | import { TableCopilotUsageComponent } from './components/usage/copilot/table-workflow-usage/table-copilot-usage.component'; 25 | import { CodespacesComponent } from './components/usage/codespaces/codespaces.component'; 26 | import { TableCodespacesUsageComponent } from './components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component'; 27 | 28 | @NgModule({ declarations: [ 29 | AppComponent, 30 | UsageComponent, 31 | ChartPieUserComponent, 32 | ChartLineUsageDailyComponent, 33 | ChartBarTopTimeComponent, 34 | TableWorkflowUsageComponent, 35 | ChartPieSkuComponent, 36 | ActionsComponent, 37 | SharedStorageComponent, 38 | FileUploadComponent, 39 | TableSharedStorageComponent, 40 | LineUsageTimeComponent, 41 | DialogBillingNavigateComponent, 42 | CopilotComponent, 43 | TableCopilotUsageComponent, 44 | CodespacesComponent, 45 | TableCodespacesUsageComponent 46 | ], 47 | bootstrap: [AppComponent], imports: [BrowserModule, 48 | MaterialModule, 49 | BrowserAnimationsModule, 50 | HighchartsChartModule, 51 | FormsModule, 52 | ReactiveFormsModule], providers: [provideHttpClient(withInterceptorsFromDi())] }) 53 | export class AppModule { } 54 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/actions.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/actions.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/actions/actions.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/actions/actions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ActionsComponent } from './actions.component'; 4 | 5 | describe('ActionsComponent', () => { 6 | let component: ActionsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ActionsComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ActionsComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/actions.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 3 | 4 | @Component({ 5 | selector: 'app-actions', 6 | templateUrl: './actions.component.html', 7 | styleUrl: './actions.component.scss', 8 | standalone: false 9 | }) 10 | export class ActionsComponent implements OnInit { 11 | @Input() data!: CustomUsageReportLine[]; 12 | @Input() currency!: string; 13 | totalMinutes: number = 0; 14 | totalCost: number = 0; 15 | 16 | constructor( 17 | public usageReportService: UsageReportService, 18 | ) { } 19 | 20 | ngOnInit() { 21 | this.usageReportService.getActionsTotalMinutes().subscribe((totalMinutes) => { 22 | this.totalMinutes = totalMinutes; 23 | }); 24 | this.usageReportService.getActionsTotalCost().subscribe((totalCost) => { 25 | this.totalCost = totalCost; 26 | }); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChartBarTopTimeComponent } from './chart-bar-top-time.component'; 4 | 5 | describe('ChartBarTopTimeComponent', () => { 6 | let component: ChartBarTopTimeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChartBarTopTimeComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChartBarTopTimeComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-bar-top-time/chart-bar-top-time.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges } from '@angular/core'; 2 | import * as Highcharts from 'highcharts'; 3 | import { ThemingService } from 'src/app/theme.service'; 4 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 5 | 6 | @Component({ 7 | selector: 'app-chart-bar-top-time', 8 | templateUrl: './chart-bar-top-time.component.html', 9 | styleUrl: './chart-bar-top-time.component.scss', 10 | standalone: false 11 | }) 12 | export class ChartBarTopTimeComponent implements OnChanges { 13 | @Input() data!: CustomUsageReportLine[]; 14 | @Input() currency!: string; 15 | Highcharts: typeof Highcharts = Highcharts; 16 | options: Highcharts.Options = { 17 | chart: { 18 | type: 'bar' 19 | }, 20 | title: { 21 | text: 'Top 10 repos by minutes used' 22 | }, 23 | subtitle: { 24 | }, 25 | yAxis: { 26 | title: { 27 | text: 'Minutes (min)' 28 | }, 29 | }, 30 | xAxis: { 31 | title: { 32 | 33 | } 34 | }, 35 | series: [{ 36 | type: 'bar', 37 | name: 'Usage', 38 | data: [ 39 | { name: 'Point 1', y: 1 }, 40 | { name: 'Point 2', y: 2 }, 41 | { name: 'Point 3', y: null }, 42 | { name: 'Point 4', y: 4 } 43 | ] 44 | }], 45 | legend: { 46 | enabled: false 47 | } 48 | }; 49 | updateFromInput: boolean = false; 50 | 51 | constructor( 52 | private themeService: ThemingService 53 | ) { 54 | this.options = { 55 | ...this.options, 56 | ...this.themeService.getHighchartsOptions(), 57 | } 58 | } 59 | 60 | ngOnChanges() { 61 | this.data = this.data.filter((line) => line.unitType === 'minutes'); 62 | this.options.series = [{ 63 | type: 'bar', 64 | name: 'Usage', 65 | data: this.data.reduce((acc, line) => { 66 | const existingItem = acc.find((a) => a.name === line.repositoryName); 67 | if (existingItem) { 68 | existingItem.y += line.value; 69 | } else { 70 | acc.push({ name: line.repositoryName, y: line.value }); 71 | } 72 | return acc; 73 | }, [] as { name: string, y: number }[]).sort((a, b) => b.y - a.y).slice(0, 10) 74 | }]; 75 | this.options.xAxis = { 76 | ...this.options.xAxis, 77 | categories: (this.options.series[0] as any).data.map((a: any) => a.name), 78 | }; 79 | this.options.title = { 80 | text: this.currency === 'minutes' ? 'Top 10 repos by minutes used' : 'Top 10 repos by cost' 81 | }; 82 | this.options.yAxis = { 83 | ...this.options.yAxis, 84 | title: { 85 | text: this.currency === 'minutes' ? 'Minutes (min)' : 'Cost (USD)' 86 | }, 87 | labels: { 88 | format: this.currency === 'cost' ? '${value}' : '{value}', 89 | } 90 | }; 91 | this.options.tooltip = { 92 | format: `{point.name}
${this.currency === 'cost' ? '${point.y:.2f}' : '{point.y} mins'}`, 93 | }; 94 | this.updateFromInput = true; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.html: -------------------------------------------------------------------------------- 1 |

2 | 18 | 19 | Grouping 20 | 21 | All 22 | Repo 23 | Runner 24 | Workflow 25 | User 26 | 27 | 28 | 29 | Time Aggregate 30 | 31 | Total 32 | Run 33 | Day 34 | Week 35 | Month 36 | 30-Day 37 | 7-Day 38 | 39 | 40 |

41 | 42 | 47 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.scss: -------------------------------------------------------------------------------- 1 | mat-button-toggle-group { 2 | margin-right: 15px; 3 | } -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChartLineUsageHourlyComponent } from './chart-line-usage-hourly.component'; 4 | 5 | describe('ChartLineUsageHourlyComponent', () => { 6 | let component: ChartLineUsageHourlyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChartLineUsageHourlyComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChartLineUsageHourlyComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, ViewChild } from '@angular/core'; 2 | import * as Highcharts from 'highcharts'; 3 | import { ThemingService } from 'src/app/theme.service'; 4 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 5 | 6 | @Component({ 7 | selector: 'app-chart-line-usage-daily', 8 | templateUrl: './chart-line-usage-daily.component.html', 9 | styleUrl: './chart-line-usage-daily.component.scss', 10 | standalone: false 11 | }) 12 | export class ChartLineUsageDailyComponent implements OnChanges { 13 | @Input() data!: CustomUsageReportLine[]; 14 | @Input() currency!: string; 15 | @ViewChild('chart') chartRef!: any; 16 | Highcharts: typeof Highcharts = Highcharts; 17 | options: Highcharts.Options = { 18 | chart: { 19 | type: 'spline', 20 | zooming: { 21 | type: 'x' 22 | } 23 | }, 24 | title: { 25 | text: 'Actions Usage Daily' 26 | }, 27 | subtitle: { 28 | }, 29 | yAxis: { 30 | title: { 31 | text: 'Minutes (min)' 32 | }, 33 | min: 0 34 | }, 35 | xAxis: { 36 | type: 'datetime', 37 | dateTimeLabelFormats: { 38 | month: '%e. %b', 39 | year: '%b' 40 | }, 41 | title: { 42 | text: 'Date' 43 | } 44 | }, 45 | series: [], 46 | legend: { 47 | align: 'right', 48 | verticalAlign: 'top', 49 | layout: 'vertical', 50 | y: 0, 51 | }, 52 | }; 53 | updateFromInput: boolean = false; 54 | chartType: 'repo' | 'total' | 'sku' | 'user' | 'workflow' = 'sku'; 55 | timeType: 'total' | 'run' | 'daily' | 'weekly' | 'monthly' | 'rolling30' | 'rolling7' = 'rolling30'; 56 | rollingDays = 30; 57 | 58 | constructor( 59 | private themeService: ThemingService, 60 | private usageReportService: UsageReportService 61 | ) { 62 | this.options = { 63 | ...this.options, 64 | ...this.themeService.getHighchartsOptions(), 65 | } 66 | } 67 | 68 | ngOnChanges() { 69 | this.rollingDays = Number(this.timeType.split('rolling')[1]); 70 | const seriesDays = this.data.reduce( 71 | (acc, line, index) => { 72 | let name = 'Total'; 73 | let timeKey = 'total'; 74 | if (this.timeType === 'run') { 75 | timeKey = `${line.workflowName}${line.date}${index}`; 76 | } else if (this.timeType === 'daily') { 77 | timeKey = line.date.toISOString().split('T')[0]; 78 | } else if (this.timeType === 'weekly') { 79 | timeKey = this.getWeekOfYear(line.date).toString(); 80 | } else if (this.timeType === 'monthly') { 81 | // get key in format YYYY-MM 82 | timeKey = line.date.toISOString().split('T')[0].slice(0, 7); 83 | } else if (this.timeType.startsWith('rolling')) { 84 | // get key in format YYYY-MM 85 | timeKey = line.date.toISOString().split('T')[0]; 86 | } else if (this.timeType === 'total') { 87 | timeKey = 'total' 88 | } 89 | if (this.chartType === 'sku') { 90 | name = this.usageReportService.formatSku(line.sku); 91 | } else if (this.chartType === 'user') { 92 | name = line.username; 93 | } else if (this.chartType === 'repo') { 94 | name = line.repositoryName; 95 | } else if (this.chartType === 'workflow') { 96 | name = line.workflowName; 97 | } else if (this.chartType === 'total') { 98 | name = 'total'; 99 | } 100 | const series = acc.find((s) => s.name === name); 101 | if (series) { 102 | if (!series.data[timeKey]) series.data[timeKey] = []; 103 | if (timeKey === 'total') { 104 | const last = series.data[timeKey][series.data[timeKey].length - 1]; 105 | series.data[timeKey].push([line.date.getTime(), (last[1] + line.value)]); 106 | } else if (this.timeType.startsWith('rolling')) { 107 | series.data[timeKey].push([line.date.getTime(), line.value]); 108 | } else { 109 | series.data[timeKey].push([line.date.getTime(), line.value]); 110 | } 111 | series.total += line.value; 112 | } else { 113 | acc.push({ 114 | name, 115 | data: { 116 | [timeKey]: [[line.date.getTime(), line.value]] 117 | }, 118 | total: line.value 119 | }); 120 | } 121 | return acc; 122 | }, 123 | [] as { name: string; data: { [key: string]: [number, number][] }, total: number }[] 124 | ).sort((a: any, b: any) => { 125 | return b.total - a.total; 126 | }).slice(0, 50); 127 | (this.options.series as { name: string; data: [number, number][] }[]) = seriesDays.map((series) => { 128 | let data: [number, number][] = []; 129 | if (this.timeType === 'total') { 130 | data = series.data['total']; 131 | } else if (this.timeType.startsWith('rolling')) { 132 | const perDay = Object.keys(series.data).reduce((acc, timeKey) => { 133 | acc.push({ 134 | total: series.data[timeKey].reduce((acc, curr) => acc + curr[1], 0), 135 | date: new Date(timeKey) 136 | }); 137 | return acc; 138 | }, [] as { 139 | total: number; 140 | date: Date; 141 | } []); 142 | 143 | data = perDay.reduce((acc, curr, index) => { 144 | acc.push([ 145 | curr.date.getTime(), 146 | perDay.slice(index > this.rollingDays ? index - this.rollingDays : 0, index).reduce((acc, curr) => acc + curr.total, 0) 147 | ]); 148 | return acc; 149 | }, [] as [number, number][]); 150 | } else { 151 | data = Object.keys(series.data).reduce((acc, timeKey) => { 152 | acc.push([ 153 | new Date(series.data[timeKey][0][0]).getTime(), 154 | series.data[timeKey].reduce((acc, curr) => acc + curr[1], 0) 155 | ]); 156 | return acc; 157 | }, [] as [number, number][]); 158 | } 159 | return { 160 | name: series.name, 161 | data 162 | } 163 | }); 164 | if (this.options.legend) this.options.legend.enabled = this.chartType === 'total' ? false : true; 165 | this.options.yAxis = { 166 | ...this.options.yAxis, 167 | title: { 168 | text: this.currency === 'minutes' ? 'Minutes (min)' : 'Cost (USD)' 169 | }, 170 | labels: { 171 | format: this.currency === 'cost' ? '${value:,.2f}' : '{value}', 172 | } 173 | }; 174 | this.options.title = { 175 | text: `Actions ${this.currency === 'cost' ? 'Cost' : 'Usage'} ${this.timeType.toUpperCase()}` 176 | }; 177 | this.options.tooltip = { 178 | format: `{series.name}
{point.x:%Y-%m-%d}
${this.currency === 'cost' ? '${point.y:,.2f}' : '{point.y} mins'}`, 179 | } 180 | this.updateFromInput = true; 181 | } 182 | 183 | redrawChart() { 184 | this.chartRef.ngOnDestroy(); 185 | this.ngOnChanges(); 186 | } 187 | 188 | getWeekOfYear(date: Date) { 189 | const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); 190 | d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); 191 | const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); 192 | const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); 193 | return weekNo; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChartPieSkuComponent } from './chart-pie-sku.component'; 4 | 5 | describe('ChartPieSkuComponent', () => { 6 | let component: ChartPieSkuComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChartPieSkuComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChartPieSkuComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-sku/chart-pie-sku.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges } from '@angular/core'; 2 | import * as Highcharts from 'highcharts'; 3 | import { ThemingService } from 'src/app/theme.service'; 4 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 5 | 6 | @Component({ 7 | selector: 'app-chart-pie-sku', 8 | templateUrl: './chart-pie-sku.component.html', 9 | styleUrl: './chart-pie-sku.component.scss', 10 | standalone: false 11 | }) 12 | export class ChartPieSkuComponent implements OnChanges { 13 | @Input() data!: CustomUsageReportLine[]; 14 | @Input() currency!: string; 15 | Highcharts: typeof Highcharts = Highcharts; 16 | options: Highcharts.Options = { 17 | chart: { 18 | type: 'pie' 19 | }, 20 | title: { 21 | text: 'Usage by Runner Type' 22 | }, 23 | tooltip: { 24 | pointFormat: '{series.name}: {point.percentage:.1f}%
Minutes: {point.y}' 25 | }, 26 | series: [{ 27 | type: 'pie', // Add the type property 28 | name: 'Usage', 29 | data: [ 30 | ['SKU1', 1], 31 | ['SKU2', 2], 32 | ['SKU3', 3] 33 | ] 34 | }], 35 | }; 36 | updateFromInput: boolean = false; 37 | 38 | constructor( 39 | private usageReportService: UsageReportService, 40 | private themeService: ThemingService 41 | ) { 42 | this.options = { 43 | ...this.options, 44 | ...this.themeService.getHighchartsOptions(), 45 | } 46 | } 47 | 48 | ngOnChanges() { 49 | this.data = this.data.filter((line) => line.unitType === 'minutes'); 50 | this.options.series = [{ 51 | type: 'pie', // Add the type property 52 | name: 'Usage', 53 | data: this.data.reduce((acc, line) => { 54 | const formattedSku = this.usageReportService.formatSku(line.sku); 55 | const index = acc.findIndex((item) => item[0] === formattedSku); 56 | if (index === -1) { 57 | acc.push([formattedSku, line.value]); 58 | } else { 59 | acc[index][1] += line.value; 60 | } 61 | return acc; 62 | }, [] as [string, number][]).sort((a, b) => b[1] - a[1]) 63 | }]; 64 | this.options.title = { 65 | text: `${this.currency === 'minutes' ? 'Usage' : 'Cost'} by runner type` 66 | }; 67 | this.options.tooltip = { 68 | ...this.options.tooltip, 69 | pointFormat: `{series.name}: {point.percentage:.1f}%
${this.currency === 'cost' ? 'Cost: ${point.y:.2f}' : 'Minutes: {point.y}'}` 70 | }; 71 | this.updateFromInput = true; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChartPieUserComponent } from './chart-pie-user.component'; 4 | 5 | describe('ChartPieOwnerComponent', () => { 6 | let component: ChartPieUserComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ChartPieUserComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChartPieUserComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/charts/chart-pie-user/chart-pie-user.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges } from '@angular/core'; 2 | import * as Highcharts from 'highcharts'; 3 | import { ThemingService } from 'src/app/theme.service'; 4 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 5 | 6 | @Component({ 7 | selector: 'app-chart-pie-user', 8 | templateUrl: './chart-pie-user.component.html', 9 | styleUrls: ['./chart-pie-user.component.scss'], 10 | standalone: false 11 | }) 12 | export class ChartPieUserComponent implements OnChanges { 13 | @Input() data!: CustomUsageReportLine[]; 14 | @Input() currency!: string; 15 | Highcharts: typeof Highcharts = Highcharts; 16 | options: Highcharts.Options = { 17 | chart: { 18 | type: 'pie' 19 | }, 20 | title: { 21 | text: 'Usage by username' 22 | }, 23 | tooltip: { 24 | pointFormat: '{series.name}: {point.percentage:.1f}%
Minutes: {point.y}' 25 | }, 26 | series: [{ 27 | type: 'pie', 28 | name: 'Usage', 29 | data: [] 30 | }] 31 | }; 32 | updateFromInput: boolean = false; 33 | 34 | constructor( 35 | private themeService: ThemingService, 36 | private usageReportService: UsageReportService 37 | ) { 38 | this.options = { 39 | ...this.options, 40 | ...this.themeService.getHighchartsOptions(), 41 | } 42 | } 43 | 44 | ngOnChanges() { 45 | this.data = this.data.filter((line) => line.unitType === 'minutes'); 46 | 47 | const aggregatedData = this.data.reduce((acc, line) => { 48 | const index = acc.findIndex((item) => item[0] === line.username); 49 | if (index === -1) { 50 | acc.push([line.username, line.value]); 51 | } else { 52 | acc[index][1] += line.value; 53 | } 54 | return acc; 55 | }, [] as [string, number][]) 56 | .sort((a, b) => b[1] - a[1]); 57 | 58 | // Take top 20 and group the rest as "Other" 59 | const topNo = 29; 60 | const topX = aggregatedData.slice(0, topNo); 61 | const remaining = aggregatedData.slice(topNo); 62 | 63 | const data = [...topX]; 64 | if (remaining.length > 0) { 65 | const otherTotal = remaining.reduce((sum, item) => sum + item[1], 0); 66 | data.push(['Other', otherTotal]); 67 | } 68 | 69 | this.options.series = [{ 70 | type: 'pie', 71 | name: 'Usage', 72 | data 73 | }]; 74 | this.options.title = { 75 | text: `${this.currency === 'minutes' ? 'Usage' : 'Cost'} by username` 76 | }; 77 | this.options.tooltip = { 78 | ...this.options.tooltip, 79 | pointFormat: `{series.name}: {point.percentage:.1f}%
${this.currency === 'cost' ? 'Cost: ${point.y:.2f}' : 'Minutes: {point.y}'}` 80 | }; 81 | this.updateFromInput = true; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Grouping 5 | 6 | Runner 7 | Repo 8 | Workflow 9 | User 10 | 11 | 12 | 13 | 14 | Filter 15 | 16 | 17 | 26 |
27 | 28 |
29 | 31 | @for (column of columns; track column) { 32 | 33 | 36 | 43 | 44 | 45 | } 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
34 | {{column.header}} 35 | 37 | {{column.cell(row)}} 38 | 42 | {{ column.footer ? column.footer() : '' }}
No data matching the filter "{{input.value}}"
54 |
55 | 56 |
-------------------------------------------------------------------------------- /src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TableWorkflowUsageComponent } from './table-workflow-usage.component'; 4 | 5 | describe('TableWorkflowUsageComponent', () => { 6 | let component: TableWorkflowUsageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TableWorkflowUsageComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TableWorkflowUsageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, Input, OnChanges, ViewChild } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { MatTableDataSource } from '@angular/material/table'; 4 | import { MatSort } from '@angular/material/sort'; 5 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 6 | 7 | interface WorkflowUsageItem { 8 | workflow: string; 9 | avgTime: number; 10 | avgCost: number; 11 | runs: number; 12 | repo: string; 13 | total: number; 14 | cost: number; 15 | pricePerUnit: number; 16 | sku: string; 17 | username: string; 18 | } 19 | 20 | interface RepoUsageItem { 21 | avgTime: number; 22 | avgCost: number; 23 | repo: string; 24 | runs: number; 25 | total: number; 26 | cost: number; 27 | sku: string; 28 | } 29 | 30 | interface SkuUsageItem { 31 | avgTime: number; 32 | avgCost: number; 33 | sku: string; 34 | runs: number; 35 | total: number; 36 | cost: number; 37 | } 38 | 39 | interface UsageColumn { 40 | sticky?: boolean; 41 | columnDef: string; 42 | header: string; 43 | cell: (element: any) => any; 44 | footer?: () => any; 45 | tooltip?: (element: any) => any; 46 | icon?: (element: any) => string; 47 | date?: Date; 48 | } 49 | 50 | @Component({ 51 | selector: 'app-table-workflow-usage', 52 | templateUrl: './table-workflow-usage.component.html', 53 | styleUrl: './table-workflow-usage.component.scss', 54 | standalone: false 55 | }) 56 | export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { 57 | columns = [] as UsageColumn[]; 58 | monthColumns = [] as UsageColumn[]; 59 | displayedColumns = this.columns.map(c => c.columnDef); 60 | @Input() data!: CustomUsageReportLine[]; 61 | @Input() currency!: string; 62 | dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property 63 | tableType: 'workflow' | 'repo' | 'sku' | 'user' = 'sku'; 64 | 65 | @ViewChild(MatPaginator) paginator!: MatPaginator; 66 | @ViewChild(MatSort) sort!: MatSort; 67 | 68 | constructor( 69 | private usageReportService: UsageReportService, 70 | ) { } 71 | 72 | ngOnChanges() { 73 | this.initializeColumns(); 74 | let usage: WorkflowUsageItem[] | RepoUsageItem[] | SkuUsageItem[] = []; 75 | let usageItems: WorkflowUsageItem[] = (usage as WorkflowUsageItem[]); 76 | usageItems = this.data.reduce((acc, line) => { 77 | const item = acc.find(a => { 78 | if (this.tableType === 'workflow') { 79 | return a.workflow === line.workflowName 80 | } else if (this.tableType === 'repo') { 81 | return a.repo === line.repositoryName; 82 | } else if (this.tableType === 'sku') { 83 | return a.sku === this.usageReportService.formatSku(line.sku); 84 | } else if (this.tableType === 'user') { 85 | return a.username === line.username; 86 | } 87 | return false 88 | }); 89 | const month: string = line.date.toLocaleString('default', { month: 'short', year: '2-digit'}); 90 | if (item) { 91 | if ((item as any)[month]) { 92 | (item as any)[month] += line.value; 93 | } else { 94 | (item as any)[month] = line.value || 0; 95 | } 96 | if (!this.columns.find(c => c.columnDef === month)) { 97 | const column: UsageColumn = { 98 | columnDef: month, 99 | header: month, 100 | cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), 101 | footer: () => { 102 | const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); 103 | return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); 104 | }, 105 | date: new Date(line.date), 106 | }; 107 | const lastMonth: string = new Date(line.date.getFullYear(), line.date.getMonth() - 1).toLocaleString('default', { month: 'short' }); 108 | const lastMonthValue = (item as any)[lastMonth]; 109 | if (lastMonthValue) { 110 | column.tooltip = (workflowItem: WorkflowUsageItem) => { 111 | return (workflowItem as any)[month + 'PercentChange']?.toFixed(2) + '%'; 112 | }; 113 | column.icon = (workflowItem: WorkflowUsageItem) => { 114 | const percentageChanged = (workflowItem as any)[month + 'PercentChange']; 115 | if (percentageChanged > 0) { 116 | return 'trending_up'; 117 | } else if (percentageChanged < 0) { 118 | return 'trending_down'; 119 | } else { 120 | return 'trending_flat'; 121 | } 122 | }; 123 | } 124 | this.columns.push(column); 125 | this.monthColumns.push(column); 126 | } 127 | item.cost += line.quantity * line.pricePerUnit; 128 | item.total += line.quantity; 129 | item.runs++; 130 | } else { 131 | acc.push({ 132 | workflow: line.workflowName, 133 | repo: line.repositoryName, 134 | total: line.quantity, 135 | cost: line.quantity * line.pricePerUnit, 136 | runs: 1, 137 | pricePerUnit: line.pricePerUnit || 0, 138 | avgCost: line.quantity * line.pricePerUnit, 139 | avgTime: line.value, 140 | [month]: line.value, 141 | sku: this.usageReportService.formatSku(line.sku), 142 | username: line.username, 143 | }); 144 | } 145 | return acc; 146 | }, [] as WorkflowUsageItem[]); 147 | 148 | usageItems.forEach((item) => { 149 | this.monthColumns.forEach((column: UsageColumn) => { 150 | const month = column.columnDef; 151 | if (!(item as any)[month]) { 152 | (item as any)[month] = 0; 153 | } 154 | const lastMonth: string = new Date(new Date().getFullYear(), this.usageReportService.monthsOrder.indexOf(month) - 1).toLocaleString('default', { month: 'short' }); 155 | const lastMonthValue = (item as any)[lastMonth]; 156 | const percentageChanged = this.calculatePercentageChange(lastMonthValue, (item as any)[month]); 157 | (item as any)[month + 'PercentChange'] = percentageChanged; 158 | }); 159 | 160 | item.avgTime = item.total / item.runs; 161 | item.avgCost = item.cost / item.runs; 162 | }); 163 | usage = usageItems; 164 | this.columns = this.columns.sort((a, b) => (!a.date || !b.date) ? 0 : a.date.getTime() - b.date.getTime()); 165 | this.displayedColumns = this.columns.map(c => c.columnDef); 166 | this.dataSource.data = usage; 167 | } 168 | 169 | ngAfterViewInit() { 170 | this.dataSource.paginator = this.paginator; 171 | this.dataSource.sort = this.sort; 172 | const initial = this.dataSource.sortData; 173 | this.dataSource.sortData = (data: (WorkflowUsageItem | RepoUsageItem | SkuUsageItem)[], sort: MatSort) => { 174 | switch (sort.active) { 175 | case 'sku': 176 | return data.sort((a, b) => { 177 | const orderA = this.usageReportService.skuOrder.indexOf(a.sku); 178 | const orderB = this.usageReportService.skuOrder.indexOf(b.sku); 179 | return sort.direction === 'asc' ? orderA - orderB : orderB - orderA; 180 | }); 181 | default: 182 | return initial(data, sort); 183 | } 184 | }; 185 | } 186 | 187 | applyFilter(event: Event) { 188 | const filterValue = (event.target as HTMLInputElement).value; 189 | this.dataSource.filter = filterValue.trim().toLowerCase(); 190 | 191 | if (this.dataSource.paginator) { 192 | this.dataSource.paginator.firstPage(); 193 | } 194 | } 195 | 196 | initializeColumns() { 197 | let columns: UsageColumn[] = []; 198 | if (this.tableType === 'workflow') { 199 | columns = [ 200 | { 201 | columnDef: 'workflow', 202 | header: 'Workflow', 203 | cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.workflow}`, 204 | sticky: true, 205 | }, 206 | { 207 | columnDef: 'repo', 208 | header: 'Repository', 209 | cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repo}`, 210 | }, 211 | { 212 | columnDef: 'runner', 213 | header: 'Runner Type', 214 | cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.sku}`, 215 | }, 216 | ]; 217 | } else if (this.tableType === 'repo') { 218 | columns = [ 219 | { 220 | columnDef: 'repo', 221 | header: 'Source repository', 222 | cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repo}`, 223 | sticky: true, 224 | }, 225 | ]; 226 | } else if (this.tableType === 'sku') { 227 | columns = [ 228 | { 229 | columnDef: 'sku', 230 | header: 'Runner', 231 | cell: (workflowItem: WorkflowUsageItem) => this.usageReportService.formatSku(workflowItem.sku), 232 | sticky: true, 233 | }, 234 | ]; 235 | } else if (this.tableType === 'user') { 236 | columns = [ 237 | { 238 | columnDef: 'username', 239 | header: 'User', 240 | cell: (workflowItem: WorkflowUsageItem) => workflowItem.username, 241 | sticky: true, 242 | }, 243 | ]; 244 | } 245 | columns.push({ 246 | columnDef: 'runs', 247 | header: 'Runs', 248 | cell: (workflowItem: WorkflowUsageItem) => `${decimalPipe.transform(workflowItem.runs)}`, 249 | footer: () => { 250 | const total = this.dataSource.data.reduce((acc, item) => acc + item.runs, 0); 251 | return decimalPipe.transform(total); 252 | } 253 | }) 254 | if (this.currency === 'minutes') { 255 | columns.push({ 256 | columnDef: 'avgTime', 257 | header: 'Avg time', 258 | cell: (workflowItem: WorkflowUsageItem) => `${durationPipe.transform(workflowItem.avgTime)}`, 259 | footer: () => durationPipe.transform(this.dataSource.data.reduce((acc, line) => acc += line.avgTime, 0) / this.dataSource.data.length) 260 | }, { 261 | columnDef: 'total', 262 | header: 'Total', 263 | cell: (workflowItem: WorkflowUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), 264 | footer: () => decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) 265 | }); 266 | } else if (this.currency === 'cost') { 267 | columns.push({ 268 | columnDef: 'avgCost', 269 | header: 'Avg run', 270 | cell: (workflowItem: WorkflowUsageItem) => currencyPipe.transform(workflowItem.avgCost), 271 | footer: () => currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc += line.cost, 0) / this.dataSource.data.length) 272 | }, { 273 | columnDef: 'cost', 274 | header: 'Total', 275 | cell: (workflowItem: WorkflowUsageItem) => currencyPipe.transform(workflowItem.cost), 276 | footer: () => currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) 277 | }); 278 | } 279 | columns[0].footer = () => 'Total'; 280 | this.columns = columns; 281 | this.monthColumns = []; 282 | this.displayedColumns = this.columns.map(c => c.columnDef); 283 | } 284 | 285 | calculatePercentageChange(oldValue: number, newValue: number) { 286 | return (oldValue === 0) ? 0 : ((newValue - oldValue) / oldValue) * 100; 287 | } 288 | } 289 | 290 | import { Pipe, PipeTransform } from '@angular/core'; 291 | import { CurrencyPipe, DecimalPipe } from '@angular/common'; 292 | 293 | @Pipe({ 294 | name: 'duration', 295 | standalone: false 296 | }) 297 | export class DurationPipe implements PipeTransform { 298 | 299 | transform(minutes: number): string { 300 | const seconds = minutes * 60; 301 | if (seconds < 60) { 302 | return `${seconds} sec`; 303 | } else if (seconds < 3600) { 304 | return `${Math.round(seconds / 60)} min`; 305 | } else { 306 | return `${Math.round(seconds / 3600)} hr`; 307 | } 308 | } 309 | 310 | } 311 | 312 | const durationPipe = new DurationPipe(); 313 | const decimalPipe = new DecimalPipe('en-US'); 314 | const currencyPipe = new CurrencyPipe('en-US'); -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/codespaces.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/codespaces.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/codespaces/codespaces.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/codespaces.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CodespacesComponent } from './codespaces.component'; 4 | 5 | describe('CodespacesComponent', () => { 6 | let component: CodespacesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [CodespacesComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(CodespacesComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/codespaces.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 3 | 4 | @Component({ 5 | selector: 'app-codespaces', 6 | templateUrl: './codespaces.component.html', 7 | styleUrl: './codespaces.component.scss', 8 | standalone: false 9 | }) 10 | export class CodespacesComponent { 11 | @Input() data!: CustomUsageReportLine[]; 12 | @Input() currency!: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Grouping 5 | 6 | Product 7 | Repository 8 | User 9 | 10 | 11 | 12 | Filter 13 | 14 | 15 |
16 | 17 |
18 | 19 | @for (column of columns; track column) { 20 | 21 | 24 | 27 | 28 | 29 | } 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
22 | {{column.header}} 23 | 25 | {{column.cell(row)}} 26 | {{ column.footer ? column.footer() : '' }}
No data matching the filter "{{input.value}}"
38 |
39 | 40 | 41 |
-------------------------------------------------------------------------------- /src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TableCodespacesUsageComponent } from './table-codespaces-usage.component'; 4 | 5 | describe('TableCodespacesUsageComponent', () => { 6 | let component: TableCodespacesUsageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TableCodespacesUsageComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TableCodespacesUsageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, Input, OnChanges, ViewChild } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { MatTableDataSource } from '@angular/material/table'; 4 | import { MatSort } from '@angular/material/sort'; 5 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 6 | 7 | interface UsageColumn { 8 | columnDef: string; 9 | header: string; 10 | cell: (element: any) => any; 11 | footer?: () => any; 12 | sticky?: boolean; 13 | } 14 | 15 | interface CodespacesUsageItem { 16 | runs: number; 17 | total: number; 18 | cost: number; 19 | pricePerUnit: number; 20 | owner: string; 21 | username: string; 22 | sku: string; 23 | unitType: string; 24 | repositorySlug: string; 25 | sticky?: boolean; 26 | } 27 | 28 | @Component({ 29 | selector: 'app-table-codespaces-usage', 30 | templateUrl: './table-codespaces-usage.component.html', 31 | styleUrl: './table-codespaces-usage.component.scss', 32 | standalone: false 33 | }) 34 | export class TableCodespacesUsageComponent implements OnChanges, AfterViewInit { 35 | columns = [] as UsageColumn[]; 36 | displayedColumns = this.columns.map(c => c.columnDef); 37 | @Input() data!: CustomUsageReportLine[]; 38 | @Input() currency!: string; 39 | dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property 40 | tableType: 'sku' | 'repo' | 'user' = 'sku'; 41 | 42 | @ViewChild(MatPaginator) paginator!: MatPaginator; 43 | @ViewChild(MatSort) sort!: MatSort; 44 | 45 | constructor( 46 | private usageReportService: UsageReportService, 47 | ) { } 48 | 49 | ngOnChanges() { 50 | this.initializeColumns(); 51 | let usage: CodespacesUsageItem[] = []; 52 | let usageItems: CodespacesUsageItem[] = (usage as CodespacesUsageItem[]); 53 | usageItems = this.data.reduce((acc, line) => { 54 | const item = acc.find(a => { 55 | if (this.tableType === 'sku') { 56 | return a.sku === line.sku; 57 | } else if (this.tableType === 'repo') { 58 | return a.repositorySlug === line.repositoryName; 59 | } else if (this.tableType === 'user') { 60 | return a.username === line.username; 61 | } 62 | return false; 63 | }); 64 | const month: string = line.date.toLocaleString('default', { month: 'short' }); 65 | if (item) { 66 | if ((item as any)[month]) { 67 | (item as any)[month] += line.value; 68 | } else { 69 | (item as any)[month] = line.value || 0; 70 | } 71 | item.total += line.value; 72 | if (!this.columns.find(c => c.columnDef === month)) { 73 | this.columns.push({ 74 | columnDef: month, 75 | header: month, 76 | cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), 77 | footer: () => { 78 | const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); 79 | return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); 80 | } 81 | }); 82 | } 83 | item.cost += line.quantity * line.pricePerUnit; 84 | item.total += line.quantity; 85 | item.runs++; 86 | } else { 87 | acc.push({ 88 | owner: line.organization, 89 | total: line.quantity, 90 | cost: line.quantity * line.pricePerUnit, 91 | runs: 1, 92 | pricePerUnit: line.pricePerUnit || 0, 93 | [month]: line.value, 94 | sku: line.sku, 95 | unitType: line.unitType, 96 | repositorySlug: line.repositoryName, 97 | username: line.username 98 | }); 99 | } 100 | return acc; 101 | }, [] as CodespacesUsageItem[]); 102 | 103 | usageItems.forEach((item) => { 104 | this.columns.forEach((column: any) => { 105 | if (!(item as any)[column.columnDef]) { 106 | (item as any)[column.columnDef] = 0; 107 | } 108 | }); 109 | }); 110 | usage = usageItems 111 | this.displayedColumns = this.columns.map(c => c.columnDef); 112 | this.dataSource.data = usage; 113 | } 114 | 115 | ngAfterViewInit() { 116 | this.dataSource.paginator = this.paginator; 117 | this.dataSource.sort = this.sort; 118 | } 119 | 120 | applyFilter(event: Event) { 121 | const filterValue = (event.target as HTMLInputElement).value; 122 | this.dataSource.filter = filterValue.trim().toLowerCase(); 123 | 124 | if (this.dataSource.paginator) { 125 | this.dataSource.paginator.firstPage(); 126 | } 127 | } 128 | 129 | initializeColumns() { 130 | let columns: UsageColumn[] = []; 131 | if (this.tableType === 'sku') { 132 | columns = [ 133 | { 134 | columnDef: 'sku', 135 | header: 'Product', 136 | cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.sku}`, 137 | sticky: true 138 | } 139 | ]; 140 | } else if (this.tableType === 'repo') { 141 | columns = [ 142 | { 143 | columnDef: 'repo', 144 | header: 'Repository', 145 | cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.repositorySlug}`, 146 | sticky: true 147 | } 148 | ]; 149 | } else if (this.tableType === 'user') { 150 | columns = [ 151 | { 152 | columnDef: 'username', 153 | header: 'User', 154 | cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.username}`, 155 | sticky: true 156 | } 157 | ]; 158 | } 159 | if (this.currency === 'minutes') { 160 | columns.push({ 161 | columnDef: 'total', 162 | header: 'Total seats', 163 | cell: (workflowItem: CodespacesUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), 164 | footer: () => decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) 165 | }); 166 | } else if (this.currency === 'cost') { 167 | columns.push({ 168 | columnDef: 'cost', 169 | header: 'Total cost', 170 | cell: (workflowItem: CodespacesUsageItem) => currencyPipe.transform(workflowItem.cost), 171 | footer: () => currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) 172 | }); 173 | } 174 | this.columns = columns; 175 | this.displayedColumns = this.columns.map(c => c.columnDef); 176 | } 177 | } 178 | 179 | import { Pipe, PipeTransform } from '@angular/core'; 180 | import { CurrencyPipe, DecimalPipe } from '@angular/common'; 181 | 182 | @Pipe({ 183 | name: 'duration', 184 | standalone: false 185 | }) 186 | export class DurationPipe implements PipeTransform { 187 | transform(minutes: number): string { 188 | const seconds = minutes * 60; 189 | if (seconds < 60) { 190 | return `${seconds} sec`; 191 | } else if (seconds < 3600) { 192 | return `${Math.round(seconds / 60)} min`; 193 | } else { 194 | return `${Math.round(seconds / 3600)} hr`; 195 | } 196 | } 197 | 198 | } 199 | 200 | const decimalPipe = new DecimalPipe('en-US'); 201 | const currencyPipe = new CurrencyPipe('en-US'); -------------------------------------------------------------------------------- /src/app/components/usage/copilot/copilot.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/usage/copilot/copilot.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/copilot/copilot.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/copilot/copilot.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CopilotComponent } from './copilot.component'; 4 | 5 | describe('CopilotComponent', () => { 6 | let component: CopilotComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [CopilotComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(CopilotComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/copilot/copilot.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 3 | 4 | @Component({ 5 | selector: 'app-copilot', 6 | templateUrl: './copilot.component.html', 7 | styleUrl: './copilot.component.scss', 8 | standalone: false 9 | }) 10 | export class CopilotComponent { 11 | @Input() data!: CustomUsageReportLine[]; 12 | @Input() currency!: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Filter 5 | 6 | 7 |
8 | 9 |
10 | 11 | @for (column of columns; track column) { 12 | 13 | 16 | 19 | 20 | 21 | } 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
14 | {{column.header}} 15 | 17 | {{column.cell(row)}} 18 | {{ column.footer ? column.footer() : '' }}
No data matching the filter "{{input.value}}"
30 |
31 | 32 | 33 |
-------------------------------------------------------------------------------- /src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TableCopilotUsageComponent } from './table-copilot-usage.component'; 4 | 5 | describe('TableWorkflowUsageComponent', () => { 6 | let component: TableCopilotUsageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TableCopilotUsageComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TableCopilotUsageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, Input, OnChanges, ViewChild, ChangeDetectorRef } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { MatTableDataSource } from '@angular/material/table'; 4 | import { MatSort } from '@angular/material/sort'; 5 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 6 | 7 | interface UsageColumn { 8 | columnDef: string; 9 | header: string; 10 | cell: (element: any) => any; 11 | footer?: () => any; 12 | sticky?: boolean; 13 | } 14 | 15 | interface CopilotUsageItem { 16 | runs: number; 17 | total: number; 18 | cost: number; 19 | pricePerUnit: number; 20 | owner: string; 21 | sticky?: boolean; 22 | } 23 | 24 | @Component({ 25 | selector: 'app-table-copilot-usage', 26 | templateUrl: './table-copilot-usage.component.html', 27 | styleUrl: './table-copilot-usage.component.scss', 28 | standalone: false 29 | }) 30 | export class TableCopilotUsageComponent implements OnChanges, AfterViewInit { 31 | columns = [] as UsageColumn[]; 32 | displayedColumns: string[] = []; 33 | @Input() data!: CustomUsageReportLine[]; 34 | @Input() currency!: string; 35 | dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property 36 | tableType = 'owner'; 37 | 38 | @ViewChild(MatPaginator) paginator!: MatPaginator; 39 | @ViewChild(MatSort) sort!: MatSort; 40 | 41 | constructor( 42 | private usageReportService: UsageReportService, 43 | private cdr: ChangeDetectorRef 44 | ) { 45 | this.initializeColumns(); 46 | } 47 | 48 | ngOnChanges() { 49 | if (!this.data) { 50 | return; // Avoid processing if data is not available yet 51 | } 52 | 53 | this.initializeColumns(); 54 | let usage: CopilotUsageItem[] = []; 55 | let usageItems: CopilotUsageItem[] = (usage as CopilotUsageItem[]); 56 | usageItems = this.data.reduce((acc, line) => { 57 | const item = acc.find(a => { 58 | if (this.tableType === 'owner') { 59 | return a.owner === line.organization; 60 | } 61 | return false; 62 | }); 63 | const month: string = line.date.toLocaleString('default', { month: 'short' }); 64 | if (item) { 65 | if ((item as any)[month]) { 66 | (item as any)[month] += line.value; 67 | } else { 68 | (item as any)[month] = line.value || 0; 69 | } 70 | item.total += line.value; 71 | if (!this.columns.find(c => c.columnDef === month)) { 72 | this.columns.push({ 73 | columnDef: month, 74 | header: month, 75 | cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), 76 | footer: () => { 77 | if (!this.dataSource?.data) return ''; 78 | const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); 79 | return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); 80 | } 81 | }); 82 | } 83 | item.cost += line.quantity * line.pricePerUnit; 84 | item.total += line.quantity; 85 | item.runs++; 86 | } else { 87 | acc.push({ 88 | owner: line.organization, 89 | total: line.quantity, 90 | cost: line.quantity * line.pricePerUnit, 91 | runs: 1, 92 | pricePerUnit: line.pricePerUnit || 0, 93 | [month]: line.value, 94 | }); 95 | } 96 | return acc; 97 | }, [] as CopilotUsageItem[]); 98 | 99 | usageItems.forEach((item) => { 100 | this.columns.forEach((column: any) => { 101 | if (!(item as any)[column.columnDef]) { 102 | (item as any)[column.columnDef] = 0; 103 | } 104 | }); 105 | }); 106 | usage = usageItems; 107 | 108 | // Update displayedColumns first 109 | this.displayedColumns = this.columns.map(c => c.columnDef); 110 | 111 | // Then update the data source 112 | this.dataSource = new MatTableDataSource(usage); 113 | 114 | // Apply sort and pagination immediately, without setTimeout 115 | if (this.sort) { 116 | this.dataSource.sort = this.sort; 117 | } 118 | if (this.paginator) { 119 | this.dataSource.paginator = this.paginator; 120 | } 121 | 122 | // Mark for check to ensure proper change detection 123 | this.cdr.markForCheck(); 124 | } 125 | 126 | ngAfterViewInit() { 127 | // We use next tick to avoid the ExpressionChangedAfterItHasBeenCheckedError 128 | Promise.resolve().then(() => { 129 | if (this.dataSource) { 130 | this.dataSource.paginator = this.paginator; 131 | this.dataSource.sort = this.sort; 132 | } 133 | }); 134 | } 135 | 136 | applyFilter(event: Event) { 137 | const filterValue = (event.target as HTMLInputElement).value; 138 | this.dataSource.filter = filterValue.trim().toLowerCase(); 139 | 140 | if (this.dataSource.paginator) { 141 | this.dataSource.paginator.firstPage(); 142 | } 143 | } 144 | 145 | initializeColumns() { 146 | let columns: UsageColumn[] = []; 147 | if (this.tableType === 'owner') { 148 | columns = [ 149 | { 150 | columnDef: 'owner', 151 | header: 'Owner', 152 | cell: (workflowItem: CopilotUsageItem) => `${workflowItem.owner}`, 153 | sticky: true 154 | } 155 | ]; 156 | if (this.currency === 'minutes') { 157 | columns.push({ 158 | columnDef: 'total', 159 | header: 'Total seats', 160 | cell: (workflowItem: CopilotUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), 161 | footer: () => { 162 | if (!this.data) return ''; 163 | return decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)); 164 | } 165 | }); 166 | } else if (this.currency === 'cost') { 167 | columns.push({ 168 | columnDef: 'cost', 169 | header: 'Total cost', 170 | cell: (workflowItem: CopilotUsageItem) => currencyPipe.transform(workflowItem.cost), 171 | footer: () => { 172 | if (!this.data) return ''; 173 | return currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)); 174 | } 175 | }); 176 | } 177 | } 178 | 179 | // Important: Clear columns before setting new ones 180 | this.columns = []; 181 | 182 | // Set new columns 183 | this.columns = columns; 184 | 185 | // Update displayedColumns immediately after updating columns 186 | this.displayedColumns = this.columns.map(c => c.columnDef); 187 | } 188 | } 189 | 190 | import { Pipe, PipeTransform } from '@angular/core'; 191 | import { CurrencyPipe, DecimalPipe } from '@angular/common'; 192 | 193 | @Pipe({ 194 | name: 'duration', 195 | standalone: false 196 | }) 197 | export class DurationPipe implements PipeTransform { 198 | transform(minutes: number): string { 199 | const seconds = minutes * 60; 200 | if (seconds < 60) { 201 | return `${seconds} sec`; 202 | } else if (seconds < 3600) { 203 | return `${Math.round(seconds / 60)} min`; 204 | } else { 205 | return `${Math.round(seconds / 3600)} hr`; 206 | } 207 | } 208 | 209 | } 210 | 211 | const decimalPipe = new DecimalPipe('en-US'); 212 | const currencyPipe = new CurrencyPipe('en-US'); -------------------------------------------------------------------------------- /src/app/components/usage/dialog-billing-navigate.html: -------------------------------------------------------------------------------- 1 |

Navigate to Billing & Plans

2 |
3 |

Enter your GitHub {{(data.isEnterprise ? 'enterprise' : 'organization')}} name

4 | 5 | {{(data.isEnterprise ? 'Enterprise' : 'Organization')}} slug 6 | 7 | 8 |

After navigating to GitHub Billing download your usage report by clicking "Get usage report".

9 |

Click the "Navigate" button below when you're ready.

10 | example screenshot 11 |
12 |
13 | 14 | 15 | 16 |
-------------------------------------------------------------------------------- /src/app/components/usage/dialog-billing-navigate.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { 3 | MAT_DIALOG_DATA, 4 | MatDialogRef, 5 | } from '@angular/material/dialog'; 6 | 7 | interface DialogDataComponent { 8 | name: string; 9 | isEnterprise: boolean; 10 | } 11 | 12 | @Component({ 13 | selector: 'app-dialog-billing-navigate', 14 | templateUrl: 'dialog-billing-navigate.html', 15 | standalone: false 16 | }) 17 | export class DialogBillingNavigateComponent { 18 | constructor( 19 | public dialogRef: MatDialogRef, 20 | @Inject(MAT_DIALOG_DATA) public data: DialogDataComponent, 21 | ) { 22 | this.data = { 23 | name: '', 24 | isEnterprise: true 25 | } 26 | } 27 | 28 | onNoClick(): void { 29 | this.dialogRef.close(); 30 | } 31 | 32 | onToggleEnterprise() { 33 | this.data.isEnterprise = !this.data.isEnterprise; 34 | } 35 | } -------------------------------------------------------------------------------- /src/app/components/usage/file-upload/file-upload.component.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 13 | -------------------------------------------------------------------------------- /src/app/components/usage/file-upload/file-upload.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/file-upload/file-upload.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/file-upload/file-upload.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FileUploadComponent } from './file-upload.component'; 4 | 5 | describe('FileUploadComponent', () => { 6 | let component: FileUploadComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ FileUploadComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(FileUploadComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/file-upload/file-upload.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-file-upload', 5 | templateUrl: './file-upload.component.html', 6 | styleUrls: ['./file-upload.component.scss'], 7 | standalone: false 8 | }) 9 | export class FileUploadComponent { 10 | @Input() text: string = 'Choose File'; 11 | @Output() fileText = new EventEmitter<{ 12 | type: 'metered' | 'copilot_premium_requests'; 13 | fileText: string; 14 | }>(); 15 | @ViewChild('fileUpload', { static: false }) fileUpload!: ElementRef; // Add this line 16 | type?: 'metered' | 'copilot_premium_requests'; 17 | 18 | constructor() { 19 | this.fileUpload = new ElementRef(null); 20 | } 21 | 22 | onFileSelected(event: Event) { 23 | const fileInput = event.target as HTMLInputElement; 24 | const file: File | null = fileInput.files ? fileInput.files[0] : null; 25 | 26 | if (file) { 27 | const reader = new FileReader(); 28 | reader.onload = () => { 29 | const fileText = reader.result as string; 30 | this.fileText.emit({ 31 | type: this.type || 'metered', 32 | fileText 33 | }); // emit the file text to the parent component 34 | }; 35 | reader.readAsText(file); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/charts/line-usage-time/line-usage-time.component.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | Total 4 | Per Repo 5 | 6 |

7 | 8 | 14 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/charts/line-usage-time/line-usage-time.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/shared-storage/charts/line-usage-time/line-usage-time.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/charts/line-usage-time/line-usage-time.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LineUsageTimeComponent } from './line-usage-time.component'; 4 | 5 | describe('LineUsageTimeComponent', () => { 6 | let component: LineUsageTimeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [LineUsageTimeComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(LineUsageTimeComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/charts/line-usage-time/line-usage-time.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, ViewChild } from '@angular/core'; 2 | import * as Highcharts from 'highcharts'; 3 | import { ThemingService } from 'src/app/theme.service'; 4 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 5 | 6 | @Component({ 7 | selector: 'app-line-usage-time', 8 | templateUrl: './line-usage-time.component.html', 9 | styleUrl: './line-usage-time.component.scss', 10 | standalone: false 11 | }) 12 | export class LineUsageTimeComponent implements OnChanges { 13 | @Input() data!: CustomUsageReportLine[]; 14 | @Input() currency!: string; 15 | Highcharts: typeof Highcharts = Highcharts; 16 | @ViewChild('chart') chartRef!: any; 17 | options: Highcharts.Options = { 18 | chart: { 19 | type: 'spline', 20 | zooming: { 21 | type: 'x' 22 | } 23 | }, 24 | title: { 25 | text: 'Shared Storage Per Day' 26 | }, 27 | subtitle: { 28 | }, 29 | yAxis: { 30 | title: { 31 | text: 'Gigabits (Gb)' 32 | }, 33 | min: 0 34 | }, 35 | xAxis: { 36 | type: 'datetime', 37 | dateTimeLabelFormats: { 38 | // don't display the year 39 | month: '%e. %b', 40 | year: '%b' 41 | }, 42 | title: { 43 | text: 'Date' 44 | } 45 | }, 46 | legend: { 47 | align: 'right', 48 | verticalAlign: 'top', 49 | layout: 'vertical', 50 | y: 0, 51 | }, 52 | series: [] 53 | }; 54 | updateFromInput: boolean = false; 55 | chartType: 'perRepo' | 'total' = 'perRepo'; 56 | 57 | constructor( 58 | private themeService: ThemingService 59 | ) { 60 | this.options = { 61 | ...this.options, 62 | ...this.themeService.getHighchartsOptions(), 63 | } 64 | } 65 | 66 | ngOnChanges() { 67 | let gbs = 0; 68 | if (this.chartType === 'total') { 69 | this.options.series = [{ 70 | type: 'spline', 71 | name: 'Usage', 72 | data: this.data.reduce((acc, line) => { 73 | gbs += line.value; 74 | acc.push([line.date.getTime(), gbs]); 75 | return acc; 76 | }, [] as [number, number][]) 77 | }]; 78 | if (this.options.legend) this.options.legend.enabled = false; 79 | } else if (this.chartType === 'perRepo') { 80 | (this.options.series as any) = this.data.reduce( 81 | (acc, line) => { 82 | gbs += line.value; 83 | if (acc.find(a => a.name === line.repositoryName)) { 84 | const existing = acc.find(a => a.name === line.repositoryName); 85 | if (existing && line.value !== 0) { 86 | existing.data.push([line.date.getTime(), line.value]); 87 | } 88 | } else { 89 | acc.push({ 90 | name: line.repositoryName, 91 | data: [ 92 | [line.date.getTime(), line.value] 93 | ] 94 | }); 95 | } 96 | return acc; 97 | }, 98 | [] as { name: string; data: [number, number][] }[] 99 | ).sort((a: any, b: any) => { 100 | return b.data[b.data.length - 1][1] - a.data[a.data.length - 1][1]; 101 | }).slice(0, 50); 102 | if (this.options.legend) this.options.legend.enabled = true; 103 | } 104 | this.options.title = { 105 | text: this.currency === 'cost' ? 'Shared Storage Cost Per Day' : 'Shared Storage Size' 106 | }; 107 | this.options.yAxis = { 108 | ...this.options.yAxis, 109 | title: { 110 | text: this.currency === 'cost' ? 'Dollars ($)' : 'Gigabits (Gb)' 111 | }, 112 | }; 113 | this.options = { 114 | ...this.options, 115 | tooltip: { 116 | ...this.options.tooltip, 117 | pointFormat: `${this.options.series!.length > 1 ? '{series.name}
' : ''}${this.currency === 'cost' ? '${point.y:.2f}/day' : '{point.y:.2f} GB/day'}`, 118 | } 119 | } 120 | this.updateFromInput = true; 121 | } 122 | 123 | toggleChartType(value: string) { 124 | (this.chartType as string) = value; 125 | this.chartRef.ngOnDestroy(); 126 | this.ngOnChanges(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/shared-storage.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/shared-storage.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/shared-storage/shared-storage.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/shared-storage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SharedStorageComponent } from './shared-storage.component'; 4 | 5 | describe('SharedStorageComponent', () => { 6 | let component: SharedStorageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [SharedStorageComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(SharedStorageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/shared-storage.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 3 | 4 | @Component({ 5 | selector: 'app-shared-storage', 6 | templateUrl: './shared-storage.component.html', 7 | styleUrl: './shared-storage.component.scss', 8 | standalone: false 9 | }) 10 | export class SharedStorageComponent { 11 | @Input() data!: CustomUsageReportLine[]; 12 | @Input() currency!: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Filter 4 | 5 | 6 |
7 | 8 | @for (column of columns; track column) { 9 | 10 | 13 | 16 | 17 | 18 | } 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
11 | {{column.header}} 12 | 14 | {{column.cell(row)}} 15 | {{ column.footer ? column.footer() : '' }}
No data matching the filter "{{input.value}}"
28 |
29 | 30 | 31 |
-------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.scss -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TableSharedStorageComponent } from './table-shared-storage.component'; 4 | 5 | describe('TableSharedStorageComponent', () => { 6 | let component: TableSharedStorageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TableSharedStorageComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TableSharedStorageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Pipe, PipeTransform, ViewChild, OnChanges, AfterViewInit, ChangeDetectorRef } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { MatTableDataSource } from '@angular/material/table'; 4 | import { MatSort } from '@angular/material/sort'; 5 | import { CustomUsageReportLine } from 'src/app/usage-report.service'; 6 | import { CurrencyPipe } from '@angular/common'; 7 | 8 | type SharedStorageUsageItem = { 9 | repo: string; 10 | count: number; 11 | avgSize: number; 12 | total: number; 13 | totalCost: number; 14 | avgCost: number; 15 | pricePerUnit: number; 16 | costPerDay: number; 17 | }; 18 | 19 | @Component({ 20 | selector: 'app-table-shared-storage', 21 | templateUrl: './table-shared-storage.component.html', 22 | styleUrl: './table-shared-storage.component.scss', 23 | standalone: false 24 | }) 25 | export class TableSharedStorageComponent implements OnChanges, AfterViewInit { 26 | columns: { 27 | columnDef: string; 28 | header: string; 29 | cell: (element: SharedStorageUsageItem) => any; 30 | footer?: () => any; 31 | sticky?: boolean; 32 | }[] = []; 33 | displayedColumns: string[] = []; 34 | @Input() data!: CustomUsageReportLine[]; 35 | @Input() currency!: string; 36 | dataSource: MatTableDataSource = new MatTableDataSource(); 37 | 38 | @ViewChild(MatPaginator) paginator!: MatPaginator; 39 | @ViewChild(MatSort) sort!: MatSort; 40 | 41 | constructor(private cdr: ChangeDetectorRef) { 42 | this.initializeColumns(); 43 | } 44 | 45 | ngOnChanges() { 46 | if (!this.data) { 47 | return; // Avoid processing if data is not available yet 48 | } 49 | 50 | this.initializeColumns(); 51 | 52 | const workflowUsage = this.data.reduce((acc, line) => { 53 | const workflowEntry = acc.find(a => a.repo === line.repositoryName); 54 | const date = line.date; 55 | const month: string = date.toLocaleString('default', { month: 'short' }); 56 | const cost = line.quantity * line.pricePerUnit; 57 | if (workflowEntry) { 58 | if ((workflowEntry as any)[month] as any) { 59 | (workflowEntry as any)[month] += line.value; 60 | } else { 61 | (workflowEntry as any)[month] = line.value; 62 | } 63 | if (!this.columns.find(c => c.columnDef === month)) { 64 | this.columns.push({ 65 | columnDef: month, 66 | header: month, 67 | cell: (sharedStorageItem: any) => this.currency === 'cost' ? currencyPipe.transform(sharedStorageItem[month]) : fileSizePipe.transform(sharedStorageItem[month]), 68 | footer: () => { 69 | if (!this.dataSource?.data) return ''; 70 | const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); 71 | return this.currency === 'cost' ? currencyPipe.transform(total) : fileSizePipe.transform(total); 72 | } 73 | }); 74 | } 75 | workflowEntry.total += line.quantity; 76 | workflowEntry.totalCost += cost; 77 | workflowEntry.count++; 78 | workflowEntry.costPerDay = cost; 79 | } else { 80 | acc.push({ 81 | repo: line.repositoryName, 82 | total: line.quantity, 83 | count: 1, 84 | totalCost: cost, 85 | avgSize: 0, 86 | avgCost: 0, 87 | [month]: line.value, 88 | pricePerUnit: line.pricePerUnit, 89 | costPerDay: cost 90 | }); 91 | } 92 | return acc; 93 | }, [] as SharedStorageUsageItem[]); 94 | 95 | workflowUsage.forEach((sharedStorageItem: SharedStorageUsageItem) => { 96 | this.columns.forEach((column) => { 97 | if (!(sharedStorageItem as any)[column.columnDef]) { 98 | (sharedStorageItem as any)[column.columnDef] = 0; 99 | } 100 | sharedStorageItem.avgSize = sharedStorageItem.total / sharedStorageItem.count; 101 | sharedStorageItem.avgCost = sharedStorageItem.totalCost / sharedStorageItem.count; 102 | }); 103 | }); 104 | 105 | // Update displayedColumns first 106 | this.displayedColumns = this.columns.map(c => c.columnDef); 107 | 108 | // Then update the data source 109 | this.dataSource = new MatTableDataSource(workflowUsage); 110 | 111 | // Apply sort and pagination immediately, without setTimeout 112 | if (this.sort) { 113 | this.dataSource.sort = this.sort; 114 | } 115 | if (this.paginator) { 116 | this.dataSource.paginator = this.paginator; 117 | } 118 | 119 | // Mark for check to ensure proper change detection 120 | this.cdr.markForCheck(); 121 | } 122 | 123 | ngAfterViewInit() { 124 | // We use next tick to avoid the ExpressionChangedAfterItHasBeenCheckedError 125 | Promise.resolve().then(() => { 126 | if (this.dataSource) { 127 | this.dataSource.paginator = this.paginator; 128 | this.dataSource.sort = this.sort; 129 | } 130 | }); 131 | } 132 | 133 | initializeColumns() { 134 | const columns: { 135 | columnDef: string, 136 | header: string, 137 | cell: (sharedStorageItem: SharedStorageUsageItem) => any, 138 | footer?: () => any, 139 | sticky?: boolean 140 | }[] = [ 141 | { 142 | columnDef: 'repo', 143 | header: 'Repository', 144 | cell: (sharedStorageItem: any) => sharedStorageItem.repo, 145 | footer: () => 'Total', 146 | sticky: true 147 | }, 148 | { 149 | columnDef: 'count', 150 | header: 'Count', 151 | cell: (sharedStorageItem: any) => sharedStorageItem.count, 152 | footer: () => { 153 | if (!this.dataSource?.data) return ''; 154 | return this.dataSource.data.reduce((acc, line) => acc + line.count, 0); 155 | }, 156 | } 157 | ]; 158 | if (this.currency == 'cost') { 159 | columns.push( 160 | { 161 | columnDef: 'total', 162 | header: 'Total Cost', 163 | cell: (sharedStorageItem: any) => currencyPipe.transform(sharedStorageItem.totalCost), 164 | footer: () => { 165 | if (!this.dataSource?.data) return ''; 166 | return currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.totalCost, 0)); 167 | } 168 | }, 169 | { 170 | columnDef: 'costPerDay', 171 | header: 'Cost Per Day', 172 | cell: (sharedStorageItem: SharedStorageUsageItem) => currencyPipe.transform(sharedStorageItem.costPerDay), 173 | footer: () => { 174 | if (!this.dataSource?.data) return ''; 175 | return currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.costPerDay, 0)); 176 | }, 177 | } 178 | ); 179 | } else if (this.currency == 'minutes') { 180 | columns.push( 181 | { 182 | columnDef: 'avgSize', 183 | header: 'Average Size', 184 | cell: (sharedStorageItem: any) => fileSizePipe.transform(sharedStorageItem.avgSize), 185 | footer: () => { 186 | if (!this.dataSource?.data || this.dataSource.data.length === 0) return ''; 187 | return fileSizePipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.avgSize, 0) / this.dataSource.data.length); 188 | }, 189 | }, 190 | { 191 | columnDef: 'total', 192 | header: 'Total', 193 | cell: (sharedStorageItem: any) => fileSizePipe.transform(sharedStorageItem.total), 194 | footer: () => { 195 | if (!this.dataSource?.data) return ''; 196 | return fileSizePipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.total, 0)); 197 | }, 198 | } 199 | ); 200 | } 201 | this.columns = columns; 202 | // Update displayedColumns immediately after updating columns 203 | this.displayedColumns = this.columns.map(c => c.columnDef); 204 | } 205 | 206 | applyFilter(event: Event) { 207 | const filterValue = (event.target as HTMLInputElement).value; 208 | this.dataSource.filter = filterValue.trim().toLowerCase(); 209 | 210 | if (this.dataSource.paginator) { 211 | this.dataSource.paginator.firstPage(); 212 | } 213 | } 214 | } 215 | 216 | type unit = 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB'; 217 | type unitPrecisionMap = { 218 | [u in unit]: number; 219 | }; 220 | 221 | const defaultPrecisionMap: unitPrecisionMap = { 222 | bytes: 0, 223 | KB: 0, 224 | MB: 1, 225 | GB: 1, 226 | TB: 2, 227 | PB: 2 228 | }; 229 | 230 | @Pipe({ 231 | name: 'formatFileSize', 232 | standalone: false 233 | }) 234 | export class FormatFileSizePipe implements PipeTransform { 235 | private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; 236 | 237 | transform(gbs: number = 0, precision: number | unitPrecisionMap = defaultPrecisionMap): string { 238 | let bytes = gbs * 1073741824; 239 | if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) return '?'; 240 | 241 | let unitIndex = 0; 242 | 243 | while (bytes >= 1024) { 244 | bytes /= 1024; 245 | unitIndex++; 246 | } 247 | 248 | const unit = this.units[unitIndex]; 249 | 250 | if (typeof precision === 'number') { 251 | return `${bytes.toFixed(+precision)}\xa0${unit}`; 252 | } 253 | return `${bytes.toFixed(precision[unit])}\xa0${unit}`; 254 | } 255 | } 256 | 257 | const fileSizePipe = new FormatFileSizePipe(); 258 | const currencyPipe = new CurrencyPipe('en-US'); 259 | -------------------------------------------------------------------------------- /src/app/components/usage/usage.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | GitHub
Usage Report Viewer 4 |

5 | 8 |
9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 |
17 |

Premium Requests

18 |
19 |
20 |
21 | 22 | Choose a date range 23 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | Workflow 36 | 38 | 39 | @for (option of _filteredWorkflows | async; track option) { 40 | {{option}} 41 | } 42 | 43 | @if (workflowControl.value) { 44 | 47 | } 48 | 49 |
50 | 52 | 53 | {{ tabSelected === 'shared-storage' ? 'GB' : tabSelected === 'actions' ? 'Minutes' : 'Seats' }} 54 | 55 | Cost 56 | 57 |
58 | 62 |
63 | 64 | 65 | 66 | commit 67 | Actions 68 | 69 | 70 |
71 | 72 |
73 |
74 | 75 | 76 | backup 77 | Shared Storage 78 | 79 | 80 |
81 | 82 |
83 |
84 | 85 | 86 | computer 87 | Codespaces 88 | 89 | 90 |
91 | 92 |
93 |
94 | 95 | 96 | 97 | Copilot 98 | 99 | 100 |
101 | 102 |
103 |
104 |
105 |
106 | 107 | 108 |
109 |

This application helps you visualize your GitHub 111 | Usage Report.
All processing is done client side and your usage report never leaves your 112 | computer.

113 |

To get started go get your usage report and then select it by clicking "Usage Report" above.

114 | 118 | 119 | 123 | 124 | 125 | 129 | 130 |
131 |
132 | -------------------------------------------------------------------------------- /src/app/components/usage/usage.component.scss: -------------------------------------------------------------------------------- 1 | // make the host component style with 100% 2 | :host { 3 | display: block; 4 | width: 100%; 5 | max-width: 1250px; 6 | } 7 | 8 | form { 9 | position: sticky; 10 | top: 0; 11 | z-index: 999; 12 | display: flex; 13 | mat-form-field { 14 | margin-right: 15px; 15 | } 16 | margin-top: 15px;; 17 | @media screen and (max-width: 767px) { 18 | flex-wrap: wrap; 19 | } 20 | } 21 | 22 | .tab-icon { 23 | margin-right: 8px; 24 | } -------------------------------------------------------------------------------- /src/app/components/usage/usage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { UsageComponent } from './usage.component'; 4 | 5 | describe('UsageComponent', () => { 6 | let component: UsageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ UsageComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(UsageComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/components/usage/usage.component.ts: -------------------------------------------------------------------------------- 1 | import { OnInit, ChangeDetectorRef, Component, OnDestroy } from '@angular/core'; 2 | import { FormControl, FormGroup } from '@angular/forms'; 3 | import { UsageReport } from 'github-usage-report/src/types'; 4 | import { Observable, Subscription, debounceTime, map, startWith } from 'rxjs'; 5 | import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; 6 | import { DialogBillingNavigateComponent } from './dialog-billing-navigate'; 7 | import { MatDialog } from '@angular/material/dialog'; 8 | import { ModelUsageReport } from 'github-usage-report'; 9 | 10 | @Component({ 11 | selector: 'app-usage', 12 | templateUrl: './usage.component.html', 13 | styleUrls: ['./usage.component.scss'], 14 | standalone: false 15 | }) 16 | export class UsageComponent implements OnInit, OnDestroy { 17 | usage!: UsageReport; 18 | usageCopilotPremiumRequests!: ModelUsageReport; 19 | usageLines = {} as { 20 | sharedStorage: CustomUsageReportLine[], 21 | codespaces: CustomUsageReportLine[], 22 | copilot: CustomUsageReportLine[], 23 | actions: CustomUsageReportLine[], 24 | }; 25 | range = new FormGroup({ 26 | start: new FormControl(), 27 | end: new FormControl() 28 | }); 29 | minDate!: Date; 30 | maxDate!: Date; 31 | workflows: string[] = []; 32 | workflow!: string; 33 | _filteredWorkflows!: Observable; 34 | workflowControl = new FormControl(''); 35 | status: string = 'Usage Report'; 36 | progress: number | null = null; 37 | subscriptions: Subscription[] = []; 38 | currency: 'minutes' | 'cost' = 'cost'; 39 | tabSelected: 'shared-storage' | 'copilot' | 'actions' = 'actions'; 40 | 41 | constructor( 42 | private usageReportService: UsageReportService, 43 | public dialog: MatDialog, 44 | private cdr: ChangeDetectorRef, 45 | ) { 46 | } 47 | 48 | ngOnInit() { 49 | this.subscriptions.push( 50 | this.range.valueChanges.pipe(debounceTime(500)).subscribe(value => { 51 | if (value.start && value.start instanceof Date && !isNaN(value.start.getTime()) && 52 | value.end && value.end instanceof Date && !isNaN(value.end.getTime())) { 53 | this.usageReportService.applyFilter({ 54 | startDate: value.start, 55 | endDate: value.end, 56 | }); 57 | } 58 | }) 59 | ); 60 | 61 | this.subscriptions.push( 62 | this.workflowControl.valueChanges.subscribe(value => { 63 | if (!value || value === '') value = ''; 64 | this.usageReportService.applyFilter({ 65 | workflow: value, 66 | }); 67 | }) 68 | ); 69 | this._filteredWorkflows = this.workflowControl.valueChanges.pipe( 70 | startWith(''), 71 | map(value => this._filterWorkflows(value || '')), 72 | ); 73 | 74 | this.subscriptions.push( 75 | this.usageReportService.getUsageFilteredByProduct('actions').subscribe((usageLines) => { 76 | this.usageLines.actions = usageLines; 77 | }), 78 | this.usageReportService.getUsageFilteredByProduct('git_lfs').subscribe((usageLines) => { 79 | this.usageLines.sharedStorage = usageLines; 80 | }), 81 | this.usageReportService.getUsageFilteredByProduct('copilot').subscribe((usageLines) => { 82 | this.usageLines.copilot = usageLines; 83 | }), 84 | this.usageReportService.getUsageFilteredByProduct('codespaces').subscribe((usageLines) => { 85 | this.usageLines.codespaces = usageLines; 86 | }), 87 | this.usageReportService.getWorkflowsFiltered().subscribe((workflows) => { 88 | this.workflows = workflows; 89 | }), 90 | ); 91 | } 92 | 93 | ngOnDestroy(): void { 94 | this.subscriptions.forEach(subscription => subscription.unsubscribe()); 95 | } 96 | 97 | async onFileText(fileText: string, type: 'metered' | 'copilot_premium_requests') { 98 | this.status = 'Parsing File...'; 99 | 100 | const progressFunction = async (_: any, progress: any): Promise => { 101 | return await new Promise((resolve) => { 102 | if (progress === this.progress) return resolve(''); 103 | this.progress = progress; 104 | this.status = `Parsing File... ${progress}%` 105 | resolve(''); 106 | }); 107 | }; 108 | const usage = await (type === 'metered' ? this.usageReportService.setUsageReportData(fileText, progressFunction) : type === 'copilot_premium_requests' ? this.usageReportService.setUsageReportCopilotPremiumRequests(fileText, progressFunction) : null); 109 | if (!usage) { 110 | this.status = 'Error parsing file. Please check the file format.'; 111 | return; 112 | } 113 | const firstLine = usage.lines[0]; 114 | const lastLine = usage.lines[usage.lines.length - 1]; 115 | this.minDate = new Date(firstLine && 'date' in firstLine ? firstLine.date : new Date()); 116 | this.maxDate = new Date(lastLine && 'date' in lastLine ? lastLine.date : new Date()); 117 | // make the date 00:00:00 118 | this.minDate.setHours(0, 0, 0, 0); 119 | this.maxDate.setHours(0, 0, 0, 0); 120 | this.range.controls.start.setValue(this.minDate, { emitEvent: false }); 121 | this.range.controls.end.setValue(this.maxDate, { emitEvent: false }); 122 | if (type === 'copilot_premium_requests') { 123 | this.usageCopilotPremiumRequests = usage as ModelUsageReport; 124 | } else { 125 | this.usage = usage as UsageReport; 126 | } 127 | this.status = 'Usage Report'; 128 | this.progress = null; 129 | this.cdr.detectChanges(); 130 | } 131 | 132 | private _filterWorkflows(workflow: string): string[] { 133 | const filterValue = workflow.toLowerCase(); 134 | return this.workflows.filter(option => option.toLowerCase().includes(filterValue)); 135 | } 136 | 137 | navigateToBilling() { 138 | const dialogRef = this.dialog.open(DialogBillingNavigateComponent); 139 | 140 | dialogRef.afterClosed().subscribe((result: any) => { 141 | if (result) { 142 | if (result.isEnterprise) { 143 | window.open(`https://github.com/enterprises/${result.name}/settings/billing`); 144 | } else { 145 | window.open(`https://github.com/organizations/${result.name}/settings/billing/summary`); 146 | } 147 | } 148 | }); 149 | } 150 | 151 | changeCurrency(currency: string) { 152 | this.currency = currency as 'minutes' | 'cost'; 153 | this.usageReportService.setValueType(this.currency); 154 | } 155 | 156 | tabChanged(event: any) { 157 | if (event.index === 0) { 158 | this.tabSelected = 'actions'; 159 | } else if (event.index === 1) { 160 | this.tabSelected = 'shared-storage'; 161 | } else if (event.index === 2) { 162 | this.tabSelected = 'copilot'; 163 | } 164 | } 165 | 166 | exportHtml() { 167 | function getStyles(doc: any) { 168 | var styles = ''; 169 | for (var i = 0; i < doc.styleSheets.length; i++) { 170 | try { 171 | var rules = doc.styleSheets[i].cssRules; 172 | for (var j = 0; j < rules.length; j++) { 173 | styles += rules[j].cssText + '\n'; 174 | } 175 | } catch (e) { 176 | console.warn('Unable to access CSS rules:', e); 177 | } 178 | } 179 | return styles; 180 | } 181 | 182 | const a = document.createElement('a'); 183 | a.download = `usage-report-${this.tabSelected}-${new Date().toISOString()}.html`; 184 | 185 | const clone = document.documentElement.cloneNode(true); 186 | const style = document.createElement('style'); 187 | style.innerHTML = getStyles(document); 188 | (clone as any).querySelector('head').appendChild(style); 189 | 190 | const bb = new Blob([(clone as any).outerHTML], { type: 'text/html' }); 191 | a.href = window.URL.createObjectURL(bb); 192 | document.body.appendChild(a); 193 | a.click(); 194 | (a as any).parentNode.removeChild(a); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/app/theme.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { ThemingService } from './theme.service'; 3 | 4 | describe('ThemeService', () => { 5 | let service: ThemingService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(ThemingService); 10 | }); 11 | 12 | it('should be created', () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/theme.service.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationRef, Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | 4 | export type Theme = "dark-theme" | "light-theme"; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ThemingService { 10 | primaryColor: string = "#00a742"; 11 | secondaryColor: string = "#7e72ff"; 12 | colors: string[] = [this.primaryColor, '#00ff18', this.secondaryColor, '#f8e044', '#ff3978', '#ffa8dc', '#ff461a', '#006de6', '#2fd9d1', '#9ee800']; 13 | themes = ["dark-theme", "light-theme"]; // <- list all themes in this array 14 | theme: BehaviorSubject = new BehaviorSubject("light-theme"); // <- initial theme 15 | highchartsOptions: Highcharts.Options = { 16 | credits: { 17 | enabled: false 18 | }, 19 | } 20 | 21 | constructor(private ref: ApplicationRef) { 22 | // Initially check if dark mode is enabled on system 23 | const darkModeOn = 24 | window.matchMedia && 25 | window.matchMedia("(prefers-color-scheme: dark)").matches; 26 | 27 | // If dark mode is enabled then directly switch to the dark-theme 28 | if(darkModeOn){ 29 | this.theme.next("dark-theme"); 30 | } 31 | 32 | // Watch for changes of the preference 33 | window.matchMedia("(prefers-color-scheme: dark)").addListener(e => { 34 | const turnOn = e.matches; 35 | this.theme.next(turnOn ? "dark-theme" : "light-theme"); 36 | 37 | // Trigger refresh of UI 38 | this.ref.tick(); 39 | }); 40 | } 41 | 42 | getPrimaryColor(): string { 43 | return this.primaryColor; 44 | } 45 | 46 | getSecondaryColor(): string { 47 | return this.secondaryColor; 48 | } 49 | 50 | getColors(): string[] { 51 | return this.colors; 52 | } 53 | 54 | setTheme(theme: Theme) { 55 | this.theme.next(theme); 56 | } 57 | 58 | getTheme(): Observable { 59 | return this.theme.asObservable(); 60 | } 61 | 62 | getHighchartsOptions(): Highcharts.Options { 63 | return this.highchartsOptions; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/usage-report.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { UsageReportService } from './usage-report.service'; 4 | 5 | describe('UsageReportService', () => { 6 | let service: UsageReportService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(UsageReportService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/usage-report.service.ts: -------------------------------------------------------------------------------- 1 | import { TitleCasePipe } from '@angular/common'; 2 | import { Injectable } from '@angular/core'; 3 | import { ModelUsageReport, readGithubUsageReport, readModelUsageReport, UsageReport, UsageReportLine } from 'github-usage-report'; 4 | import { BehaviorSubject, Observable, map } from 'rxjs'; 5 | 6 | interface Filter { 7 | startDate: Date; 8 | endDate: Date; 9 | workflow: string; 10 | sku: string; 11 | } 12 | 13 | type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces'; 14 | 15 | export interface CustomUsageReportLine extends UsageReportLine { 16 | value: number; 17 | } 18 | 19 | export interface CustomUsageReport extends UsageReport { 20 | lines: CustomUsageReportLine[]; 21 | } 22 | 23 | @Injectable({ 24 | providedIn: 'root' 25 | }) 26 | export class UsageReportService { 27 | usageReportData!: string; 28 | usageReportPremiumRequestsData!: string; 29 | usageReport!: CustomUsageReport; 30 | usageReportCopilotPremiumRequests!: ModelUsageReport; 31 | usageReportFiltered: BehaviorSubject = new BehaviorSubject([]); 32 | usageReportFilteredProduct: { [key: string]: Observable } = {}; 33 | filters: Filter = { 34 | startDate: new Date(), 35 | endDate: new Date(), 36 | workflow: '', 37 | sku: '', 38 | } as Filter; 39 | days = 0; 40 | owners: string[] = []; 41 | repositories: string[] = []; 42 | workflows: string[] = []; 43 | skus: string[] = []; 44 | products: string[] = []; 45 | usernames: string[] = []; 46 | valueType: BehaviorSubject<'minutes' | 'cost'> = new BehaviorSubject<'minutes' | 'cost'>('cost') 47 | skuMapping: { [key: string]: string } = { 48 | "actions_linux": 'Ubuntu 2', 49 | "actions_linux_16_core": 'Ubuntu 16', 50 | "actions_linux_16_core_arm": 'Ubuntu 16 (ARM)', 51 | "actions_linux_2_core_arm": 'Ubuntu 2 (ARM)', 52 | "actions_linux_32_core": 'Ubuntu 32', 53 | "actions_linux_32_core_arm": 'Ubuntu 32 (ARM)', 54 | "actions_linux_4_core": 'Ubuntu 4', 55 | "actions_linux_4_core_arm": 'Ubuntu 4 (ARM)', 56 | "actions_linux_4_core_gpu": 'Ubuntu 4 (GPU)', 57 | "actions_linux_64_core": 'Ubuntu 64', 58 | "actions_linux_64_core_arm": 'Ubuntu 64 (ARM)', 59 | "actions_linux_8_core": 'Ubuntu 8', 60 | "actions_linux_8_core_arm": 'Ubuntu 8 (ARM)', 61 | "actions_linux_2_core_advanced": 'Ubuntu 2 (Advanced)', 62 | "actions_macos": 'MacOS 3', 63 | "actions_macos_12_core": 'MacOS 12', 64 | "actions_macos_8_core": 'MacOS 8', 65 | "actions_macos_large": 'MacOS 12 (x86)', 66 | "actions_macos_xlarge": 'MacOS 6 (M1)', 67 | "actions_self_hosted_macos": 'MacOS (Self-Hosted)', 68 | "actions_windows": 'Windows 2', 69 | "actions_windows_16_core": 'Windows 16', 70 | "actions_windows_16_core_arm": 'Windows 16 (ARM)', 71 | "actions_windows_2_core_arm": 'Windows 2 (ARM)', 72 | "actions_windows_32_core": 'Windows 32', 73 | "actions_windows_32_core_arm": 'Windows 32 (ARM)', 74 | "actions_windows_4_core": 'Windows 4', 75 | "actions_windows_4_core_arm": 'Windows 4 (ARM)', 76 | "actions_windows_4_core_gpu": 'Windows 4 (GPU)', 77 | "actions_windows_64_core": 'Windows 64', 78 | "actions_windows_64_core_arm": 'Windows 64 (ARM)', 79 | "actions_windows_8_core": 'Windows 8', 80 | "actions_windows_8_core_arm": 'Windows 8 (ARM)', 81 | "actions_storage": 'Actions Storage', 82 | "actions_unknown": 'Actions Unknown', 83 | "copilot_enterprise": 'Copilot Enterprise', 84 | "copilot_for_business": 'Copilot Business', 85 | "git_lfs_storage": 'Git LFS Storage', 86 | "packages_storage": 'Packages Storage', 87 | }; 88 | skuOrder = [ 89 | 'actions_linux', 90 | 'actions_linux_4_core', 91 | 'actions_linux_8_core', 92 | 'actions_linux_16_core', 93 | 'actions_linux_32_core', 94 | 'actions_linux_64_core', 95 | 'actions_windows', 96 | // 'actions_windows_4_core', DOESN'T EXIST 97 | 'actions_windows_8_core', 98 | 'actions_windows_16_core', 99 | 'actions_windows_32_core', 100 | 'actions_windows_64_core', 101 | 'actions_macos', 102 | 'actions_macos_12_core', 103 | 'actions_macos_large', 104 | 'actions_macos_xlarge', 105 | 'actions_storage', 106 | 'copilot_for_business', 107 | ].map(sku => this.formatSku(sku)); 108 | monthsOrder = [ 109 | 'January', 110 | 'February', 111 | 'March', 112 | 'April', 113 | 'May', 114 | 'June', 115 | 'July', 116 | 'August', 117 | 'September', 118 | 'October', 119 | 'November', 120 | 'December', 121 | ]; 122 | 123 | constructor() { 124 | } 125 | 126 | get getUsageReport(): UsageReport { 127 | return this.usageReport; 128 | } 129 | 130 | get getWorkflows(): string[] { 131 | return this.workflows; 132 | } 133 | 134 | setValueType(value: 'minutes' | 'cost') { 135 | this.usageReport.lines.forEach(line => { 136 | if (value === 'minutes') { 137 | line.value = (line.quantity) || 0; 138 | } else { 139 | line.value = (line.quantity * line.pricePerUnit) || 0; 140 | } 141 | }); 142 | this.usageReportFiltered.next(this.usageReport.lines); 143 | this.valueType.next(value); 144 | } 145 | 146 | async setUsageReportCopilotPremiumRequests(usageReportData: string, cb?: (usageReport: CustomUsageReport, percent: number) => void): Promise { 147 | this.usageReportPremiumRequestsData = usageReportData; 148 | await readModelUsageReport(this.usageReportPremiumRequestsData).then((report) => { 149 | this.usageReportCopilotPremiumRequests = report; 150 | }); 151 | return this.usageReportCopilotPremiumRequests; 152 | } 153 | 154 | async setUsageReportData(usageReportData: string, cb?: (usageReport: CustomUsageReport, percent: number) => void): Promise { 155 | this.usageReportData = usageReportData; 156 | this.usageReport = await readGithubUsageReport(this.usageReportData) as CustomUsageReport; 157 | cb?.(this.usageReport, 100); 158 | this.filters.startDate = this.usageReport.startDate; 159 | this.filters.endDate = this.usageReport.endDate; 160 | this.owners = []; 161 | this.repositories = []; 162 | this.workflows = []; 163 | this.skus = []; 164 | this.products = []; 165 | this.usernames = []; 166 | this.usageReport.lines.forEach(line => { 167 | if (!this.owners.includes(line.organization)) { 168 | this.owners.push(line.organization); 169 | } 170 | if (!this.repositories.includes(line.repositoryName)) { 171 | this.repositories.push(line.repositoryName); 172 | } 173 | if (!this.workflows.includes(line.workflowName)) { 174 | this.workflows.push(line.workflowName); 175 | } 176 | if (!this.skus.includes(line.sku)) { 177 | this.skus.push(line.sku); 178 | } 179 | if (!this.products.includes(line.product)) { 180 | this.products.push(line.product); 181 | } 182 | if (!this.usernames.includes(line.username)) { 183 | this.usernames.push(line.username); 184 | } 185 | }); 186 | this.setValueType(this.valueType.value); 187 | console.log('Usage Report Loaded:', this.usageReport); 188 | return this.usageReport; 189 | } 190 | 191 | applyFilter(filter: { 192 | startDate?: Date, 193 | endDate?: Date, 194 | workflow?: string, 195 | sku?: string, 196 | }): void { 197 | Object.assign(this.filters, filter); 198 | let filtered = this.usageReport.lines; 199 | if (this.filters.sku) { 200 | filtered = filtered.filter(line => line.sku === this.filters.sku); 201 | } 202 | if (this.filters.workflow) { 203 | filtered = filtered.filter(line => line.workflowName === this.filters.workflow); 204 | } 205 | if (this.filters.startDate && this.filters.endDate) { 206 | filtered = filtered.filter(line => { 207 | return line.date >= this.filters.startDate && line.date <= this.filters.endDate; 208 | }); 209 | } 210 | this.usageReportFiltered.next(filtered); 211 | } 212 | 213 | getUsageReportFiltered(): Observable { 214 | return this.usageReportFiltered.asObservable(); 215 | } 216 | 217 | getUsageFilteredByProduct(product: Product | Product[]): Observable { 218 | const _products = Array.isArray(product) ? product : [product]; 219 | return this.getUsageReportFiltered().pipe( 220 | map(lines => lines.filter(line => _products.some(p => line.product.includes(p)))), 221 | ); 222 | } 223 | 224 | getWorkflowsFiltered(): Observable { 225 | return this.getUsageFilteredByProduct('actions').pipe( 226 | map(lines => lines.map(line => line.workflowName).filter((workflow, index, self) => self.indexOf(workflow) === index)), 227 | ) 228 | } 229 | 230 | getActionsTotalMinutes(): Observable { 231 | return this.getUsageFilteredByProduct('actions').pipe( 232 | map(lines => lines.reduce((total, line) => total + line.quantity, 0)), 233 | ) 234 | } 235 | 236 | getActionsTotalCost(): Observable { 237 | return this.getUsageFilteredByProduct('actions').pipe( 238 | map(lines => lines.reduce((total, line) => total + line.pricePerUnit, 0)) 239 | ) 240 | } 241 | 242 | getValueType(): Observable<'minutes' | 'cost'> { 243 | return this.valueType.asObservable(); 244 | } 245 | 246 | formatSku(sku: string) { 247 | if (!sku) return sku; 248 | if (this.skuMapping[sku]) return this.skuMapping[sku]; 249 | const skuParts = sku.split('Compute - '); 250 | if (skuParts.length < 2) return sku; 251 | const runtime = skuParts[1]; 252 | let formatted = runtime.replaceAll('_', ' ').replace(' CORE', ''); 253 | formatted = titlecasePipe.transform(formatted); 254 | formatted = formatted.replace('Macos', 'MacOS'); 255 | if (formatted.includes('ARM')) { 256 | return `${formatted} (ARM)` 257 | } 258 | return formatted; 259 | } 260 | } 261 | 262 | const titlecasePipe = new TitleCasePipe(); -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/chrome_5PUHU4YqD9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/assets/chrome_5PUHU4YqD9.png -------------------------------------------------------------------------------- /src/assets/chrome_ZrMnUYgg9f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/assets/chrome_ZrMnUYgg9f.png -------------------------------------------------------------------------------- /src/assets/chrome_htTVyAJEel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/assets/chrome_htTVyAJEel.png -------------------------------------------------------------------------------- /src/assets/github-copilot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | GitHub Copilot 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/github-mark-green.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/screencapture-austenstone-github-io-github-actions-usage-report-2024-02-06-10_46_53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/assets/screencapture-austenstone-github-io-github-actions-usage-report-2024-02-06-10_46_53.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/austenstone/github-actions-usage-report/689eba0ddfca88fba3b0e4feea2c4b9a2837813d/src/favicon.ico -------------------------------------------------------------------------------- /src/highcharts.github.theme.ts: -------------------------------------------------------------------------------- 1 | import * as Highcharts from 'highcharts'; 2 | 3 | const tooltipHeaderFormat = 4 | ''; 5 | 6 | const tooltipPointFormat = 7 | ''; 8 | 9 | const tooltipFooterFormat = '
{point.key}
{series.name}: {point.y}
'; 10 | 11 | const xAxisConfig: Highcharts.XAxisOptions = { 12 | tickWidth: 0, 13 | lineWidth: 0, 14 | gridLineColor: 'var(--mat-sys-outline)', 15 | gridLineDashStyle: 'Dot', 16 | // lineColor: 'var(--mat-sys-outline)', 17 | labels: { 18 | style: { 19 | color: 'var(--mat-sys-on-surface)', 20 | font: 'var(--mat-sys-body-large)' 21 | } 22 | }, 23 | title: { 24 | text: undefined, 25 | style: { 26 | color: 'var(--mat-sys-on-surface-variant)', 27 | font: 'var(--mat-sys-body-large)' 28 | } 29 | }, 30 | lineColor: 'var(--mat-sys-outline-variant)', 31 | tickColor: 'var(--mat-sys-outline-variant)' 32 | }; 33 | 34 | const yAxisConfig: Highcharts.YAxisOptions = { 35 | // Same config as xAxis but with YAxis type 36 | ...xAxisConfig 37 | }; 38 | 39 | const theme: Highcharts.Options = { 40 | accessibility: { 41 | keyboardNavigation: { 42 | order: ['legend', 'series'], 43 | }, 44 | }, 45 | colors: [ 46 | 'var(--data-blue-color-emphasis, var(--data-blue-color))', 47 | 'var(--data-green-color-emphasis, var(--data-green-color))', 48 | 'var(--data-orange-color-emphasis, var(--data-orange-color))', 49 | 'var(--data-pink-color-emphasis, var(--data-pink-color))', 50 | 'var(--data-yellow-color-emphasis, var(--data-yellow-color))', 51 | 'var(--data-red-color-emphasis, var(--data-red-color))', 52 | 'var(--data-purple-color-emphasis, var(--data-purple-color))', 53 | 'var(--data-auburn-color-emphasis, var(--data-auburn-color))', 54 | 'var(--data-teal-color-emphasis, var(--data-teal-color))', 55 | 'var(--data-gray-color-emphasis, var(--data-gray-color))', 56 | ], 57 | caption: { 58 | align: 'left', 59 | style: { 60 | color: 'var(--fgColor-muted)', 61 | }, 62 | verticalAlign: 'top', 63 | }, 64 | title: { 65 | align: 'left', 66 | style: { 67 | color: 'var(--fgColor-default)', 68 | }, 69 | text: undefined, 70 | }, 71 | subtitle: { 72 | align: 'left', 73 | style: { 74 | color: 'var(--fgColor-muted)', 75 | fontSize: 'var(--text-body-size-small)', 76 | }, 77 | }, 78 | tooltip: { 79 | backgroundColor: 'var(--bgColor-default)', 80 | borderRadius: 6, 81 | borderColor: 'var(--borderColor-muted)', 82 | borderWidth: 1, 83 | shape: 'rect', 84 | padding: 10, 85 | shadow: { 86 | offsetX: 2, 87 | offsetY: 2, 88 | opacity: 0.02, 89 | width: 4, 90 | color: 'var(--shadowColor-default)', 91 | }, 92 | style: { 93 | color: 'var(--fgColor-default)', 94 | fontFamily: 'var(--fontStack-sansSerif)', 95 | fontSize: 'var(--text-body-size-small)', 96 | }, 97 | useHTML: true, 98 | headerFormat: tooltipHeaderFormat, 99 | pointFormat: tooltipPointFormat, 100 | footerFormat: tooltipFooterFormat, 101 | }, 102 | credits: { 103 | enabled: false, 104 | }, 105 | chart: { 106 | animation: false, 107 | events: { 108 | // afterA11yUpdate() { 109 | // // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | // const container = (this as any).container 111 | // container?.setAttribute('role', 'application') 112 | // }, 113 | }, 114 | spacing: [4, 0, 4, 0], 115 | backgroundColor: 'var(--bgColor-default, var(--color-canvas-default))', 116 | style: { 117 | fontFamily: 'var(--fontStack-sansSerif)', 118 | fontSize: 'var(--text-body-size-small)', 119 | color: 'var(--fgColor-default)', 120 | }, 121 | resetZoomButton: { 122 | theme: { 123 | fill: 'var(--bgColor-default)', 124 | stroke: 'var(--borderColor-default)', 125 | style: { 126 | color: 'var(--fgColor-default)', 127 | fontFamily: 'var(--fontStack-sansSerif)', 128 | fontSize: 'var(--text-body-size-small)', 129 | cursor: 'pointer', 130 | userSelect: 'none', 131 | minWidth: '64px', 132 | display: 'inline-flex', 133 | alignItems: 'center', 134 | justifyContent: 'center', 135 | height: 24 136 | }, 137 | states: { 138 | hover: { 139 | fill: 'var(--bgColor-muted)', 140 | style: { 141 | color: 'var(--fgColor-default)' 142 | } 143 | }, 144 | select: { 145 | fill: 'var(--bgColor-emphasis)', 146 | } 147 | }, 148 | paddingLeft: 12, 149 | paddingRight: 12, 150 | height: 24, 151 | r: 4 152 | }, 153 | position: { 154 | align: 'right', 155 | verticalAlign: 'top', 156 | x: -10, 157 | y: 10 158 | } 159 | }, 160 | width: null, 161 | height: null 162 | }, 163 | legend: { 164 | itemStyle: { 165 | fontSize: 'var(--text-body-size-small)', 166 | font: 'var(--fontStack-sansSerif)', 167 | color: 'var(--fgColor-default)', 168 | }, 169 | align: 'left', 170 | verticalAlign: 'top', 171 | x: -8, 172 | y: -12, 173 | itemHoverStyle: { 174 | color: 'var(--fgColor-default)', 175 | }, 176 | title: { 177 | style: { 178 | color: 'var(--fgColor-default)', 179 | }, 180 | }, 181 | }, 182 | navigation: { 183 | buttonOptions: { 184 | align: 'right', 185 | verticalAlign: 'top', 186 | theme: { 187 | fill: 'var(--mat-sys-surface-container)', 188 | stroke: 'var(--mat-sys-outline)', 189 | states: { 190 | hover: { 191 | fill: 'var(--mat-sys-surface-container-high)', 192 | style: { 193 | color: 'var(--mat-sys-on-surface)' 194 | } 195 | }, 196 | select: { 197 | fill: 'var(--mat-sys-surface-container-highest)', 198 | style: { 199 | color: 'var(--mat-sys-on-surface)' 200 | } 201 | } 202 | }, 203 | } 204 | } as Highcharts.NavigationButtonOptions, 205 | menuStyle: { 206 | background: 'var(--mat-sys-surface-container)', 207 | color: 'var(--mat-sys-on-surface)', 208 | border: '0px solid var(--mat-sys-outline)', 209 | borderRadius: 4, 210 | padding: '8px 0', 211 | boxShadow: '0 2px 4px rgba(0,0,0,0.2)', 212 | zIndex: 1000, 213 | }, 214 | menuItemHoverStyle: { 215 | background: 'var(--mat-sys-surface-container-highest)', 216 | color: 'var(--mat-sys-on-surface)', 217 | cursor: 'pointer', 218 | transition: 'background 200ms cubic-bezier(0.4, 0, 0.2, 1)' 219 | }, 220 | menuItemStyle: { 221 | color: 'var(--mat-sys-on-surface)', 222 | fontSize: '14px', 223 | padding: '8px 16px', 224 | fontFamily: 'var(--mat-sys-body-large-font)', 225 | fontWeight: 'var(--mat-sys-body-large-weight)', 226 | transition: 'background 200ms cubic-bezier(0.4, 0, 0.2, 1)', 227 | } 228 | }, 229 | exporting: { 230 | fallbackToExportServer: false, 231 | enabled: true, 232 | buttons: { 233 | contextButton: { 234 | symbol: 'menu', 235 | symbolStroke: 'var(--fgColor-default)', 236 | symbolStrokeWidth: 2, 237 | theme: { 238 | fill: 'var(--bgColor-default)', 239 | stroke: 'var(--borderColor-default)' 240 | }, 241 | } 242 | }, 243 | chartOptions: { 244 | plotOptions: { 245 | series: { 246 | dataLabels: { 247 | enabled: true, 248 | style: { 249 | fontSize: 'var(--text-body-size-small)', 250 | fontWeight: 'normal', 251 | textOutline: 'none' 252 | } 253 | } 254 | } 255 | } 256 | } 257 | }, 258 | plotOptions: { 259 | series: { 260 | animation: false, 261 | }, 262 | spline: { 263 | animation: false, 264 | }, 265 | bar: { 266 | borderColor: 'var(--bgColor-default)', 267 | }, 268 | column: { 269 | borderColor: 'var(--bgColor-default)', 270 | borderRadius: 2, 271 | borderWidth: 0 272 | }, 273 | pie: { 274 | borderWidth: 0, 275 | borderRadius: 2, 276 | dataLabels: { 277 | style: { 278 | fontFamily: 'var(--fontStack-sansSerif)', 279 | color: 'var(--fgColor-default)', 280 | fontSize: 'var(--text-body-size-small)', 281 | fontWeight: 'normal', 282 | textOutline: 'none', 283 | }, 284 | distance: 20, 285 | connectorWidth: 1, 286 | connectorColor: 'var(--borderColor-muted)' 287 | } 288 | } 289 | }, 290 | xAxis: { 291 | tickWidth: 0, 292 | lineWidth: 1, 293 | gridLineColor: 'var(--borderColor-muted)', 294 | gridLineDashStyle: 'Dash', 295 | lineColor: 'var(--borderColor-default)', 296 | labels: { 297 | style: { 298 | color: 'var(--fgColor-muted)', 299 | fontSize: 'var(--text-body-size-small)', 300 | }, 301 | }, 302 | title: { 303 | style: { 304 | color: 'var(--fgColor-muted)', 305 | fontSize: 'var(--text-body-size-small)', 306 | }, 307 | }, 308 | }, 309 | yAxis: [yAxisConfig], 310 | lang: { 311 | thousandsSep: ',', 312 | }, 313 | drilldown: { 314 | breadcrumbs: { 315 | position: { 316 | align: 'right' 317 | }, 318 | buttonTheme: { 319 | style: { 320 | color: 'var(--fgColor-accent)', 321 | fontFamily: 'var(--fontStack-sansSerif)', 322 | fontSize: 'var(--text-body-size-small)', 323 | }, 324 | states: { 325 | hover: { 326 | fill: 'var(--bgColor-muted)', 327 | style: { 328 | color: 'var(--fgColor-default)' 329 | } 330 | }, 331 | select: { 332 | style: { 333 | color: 'var(--fgColor-default)' 334 | } 335 | } 336 | } 337 | }, 338 | separator: { 339 | style: { 340 | color: 'var(--fgColor-muted)', 341 | } 342 | } 343 | }, 344 | activeAxisLabelStyle: { 345 | color: 'var(--fgColor-accent)', 346 | textDecoration: 'none', 347 | fontWeight: 'bold', 348 | textOutline: 'none', 349 | cursor: 'pointer' 350 | }, 351 | activeDataLabelStyle: { 352 | color: 'var(--fgColor-accent)', 353 | textDecoration: 'none', 354 | fontWeight: 'bold', 355 | textOutline: 'none', 356 | cursor: 'pointer' 357 | }, 358 | }, 359 | } 360 | Highcharts.setOptions(theme); 361 | -------------------------------------------------------------------------------- /src/highcharts.theme.ts: -------------------------------------------------------------------------------- 1 | import * as Highcharts from 'highcharts'; 2 | 3 | const tooltipHeaderFormat = 4 | ''; 5 | 6 | const tooltipPointFormat = 7 | ''; 8 | 9 | const tooltipFooterFormat = '
{point.key}
{series.name}: {point.y}
'; 10 | 11 | const xAxisConfig: Highcharts.XAxisOptions = { 12 | tickWidth: 0, 13 | lineWidth: 0, 14 | gridLineColor: 'var(--mat-sys-outline)', 15 | gridLineDashStyle: 'Dot', 16 | // lineColor: 'var(--mat-sys-outline)', 17 | labels: { 18 | style: { 19 | color: 'var(--mat-sys-on-surface)', 20 | font: 'var(--mat-sys-body-large)' 21 | } 22 | }, 23 | title: { 24 | text: undefined, 25 | style: { 26 | color: 'var(--mat-sys-on-surface-variant)', 27 | font: 'var(--mat-sys-body-large)' 28 | } 29 | }, 30 | lineColor: 'var(--mat-sys-outline-variant)', 31 | tickColor: 'var(--mat-sys-outline-variant)' 32 | }; 33 | 34 | const yAxisConfig: Highcharts.YAxisOptions = { 35 | // Same config as xAxis but with YAxis type 36 | ...xAxisConfig 37 | }; 38 | export const colors = (style: string = 'emphasis') => [ 39 | `var(--data-blue-color-${style}, var(--data-blue-color))`, 40 | `var(--data-green-color-${style}, var(--data-green-color))`, 41 | `var(--data-orange-color-${style}, var(--data-orange-color))`, 42 | `var(--data-pink-color-${style}, var(--data-pink-color))`, 43 | `var(--data-yellow-color-${style}, var(--data-yellow-color))`, 44 | `var(--data-red-color-${style}, var(--data-red-color))`, 45 | `var(--data-purple-color-${style}, var(--data-purple-color))`, 46 | `var(--data-auburn-color-${style}, var(--data-auburn-color))`, 47 | `var(--data-teal-color-${style}, var(--data-teal-color))`, 48 | `var(--data-gray-color-${style}, var(--data-gray-color))`, 49 | ] 50 | const theme: Highcharts.Options = { 51 | colors: [ 52 | 'var(--mat-sys-primary)', 53 | 'var(--mat-sys-secondary)', 54 | 'var(--mat-sys-tertiary)', 55 | 'var(--mat-sys-primary-container)', 56 | 'var(--mat-sys-secondary-container)', 57 | 'var(--mat-sys-tertiary-container)', 58 | 'var(--mat-sys-inverse-primary)', 59 | 'var(--mat-sys-error)', 60 | 'var(--mat-sys-on-error)' 61 | ], 62 | chart: { 63 | backgroundColor: undefined, // 'var(--mat-sys-surface)', 64 | borderRadius: 16, 65 | style: { 66 | fontFamily: 'var(--mat-sys-body-large-font)' 67 | }, 68 | animation: { 69 | duration: 300 70 | }, 71 | spacing: [20, 20, 20, 20], 72 | resetZoomButton: { 73 | theme: { 74 | fill: 'var(--mdc-filled-button-container-color)', 75 | stroke: 'none', 76 | style: { 77 | color: 'var(--mdc-filled-button-label-text-color)', 78 | font: 'var(--mdc-filled-button-label-text-font, var(--mat-app-label-large-font))', 79 | fontSize: 'var(--mdc-filled-button-label-text-size)', 80 | letterSpacing: 'var(--mdc-filled-button-label-text-tracking)', 81 | fontWeight: 'var(--mdc-filled-button-label-text-weight)', 82 | cursor: 'pointer', 83 | transition: 'box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1)', 84 | userSelect: 'none', 85 | minWidth: '64px', 86 | display: 'inline-flex', 87 | alignItems: 'center', 88 | justifyContent: 'center', 89 | height: 36 90 | }, 91 | states: { 92 | hover: { 93 | fill: 'var(--mdc-filled-button-container-color)', 94 | style: { 95 | color: 'var(--mdc-filled-button-label-text-color)' 96 | } 97 | }, 98 | select: { 99 | fill: 'var(--mdc-filled-button-container-color)', 100 | } 101 | }, 102 | paddingLeft: 24, 103 | paddingRight: 24, 104 | height: 24, 105 | r: 24 106 | }, 107 | position: { 108 | align: 'right', 109 | verticalAlign: 'top', 110 | x: -10, 111 | y: 10 112 | } 113 | }, 114 | width: null, // Let chart size flexibly 115 | height: null 116 | }, 117 | drilldown: { 118 | breadcrumbs: { 119 | position: { 120 | align: 'right' 121 | }, 122 | buttonTheme: { 123 | // fill: 'var(--mat-sys-surface-container)', 124 | style: { 125 | color: 'var(--mat-sys-primary)', 126 | fontFamily: 'var(--mat-sys-body-large-font)', 127 | fontWeight: 'var(--mat-sys-body-large-weight)' 128 | }, 129 | // stroke: 'var(--mat-sys-outline)', 130 | // 'stroke-width': 1, 131 | states: { 132 | hover: { 133 | fill: 'var(--mat-sys-surface-container)', 134 | style: { 135 | color: 'var(--mat-sys-on-surface)' 136 | } 137 | }, 138 | select: { 139 | // fill: 'var(--mat-sys-surface-container-highest)', 140 | style: { 141 | color: 'var(--mat-sys-on-surface)' 142 | } 143 | } 144 | } 145 | }, 146 | separator: { 147 | style: { 148 | color: 'var(--mat-sys-on-surface-variant)', 149 | } 150 | } 151 | }, 152 | activeAxisLabelStyle: { 153 | color: 'var(--mat-sys-primary)', 154 | textDecoration: 'none', 155 | fontWeight: 'var(--mat-sys-title-medium-weight)', 156 | textOutline: 'none', 157 | cursor: 'pointer' 158 | }, 159 | activeDataLabelStyle: { 160 | color: 'var(--mat-sys-primary)', 161 | textDecoration: 'none', 162 | fontWeight: 'var(--mat-sys-title-medium-weight)', 163 | textOutline: 'none', 164 | cursor: 'pointer' 165 | }, 166 | // drillUpButton: { 167 | // relativeTo: 'spacingBox', 168 | // position: { 169 | // y: 0, 170 | // x: 0 171 | // }, 172 | // theme: { 173 | // fill: 'var(--mat-sys-surface-container)', 174 | // 'stroke-width': 1, 175 | // stroke: 'var(--mat-sys-outline)', 176 | // r: 4, 177 | // states: { 178 | // hover: { 179 | // fill: 'var(--mat-sys-surface-container-high)' 180 | // } 181 | // } 182 | // } 183 | // } 184 | }, 185 | lang: { 186 | thousandsSep: ',', 187 | }, 188 | title: { 189 | text: undefined, 190 | align: 'left', 191 | style: { 192 | color: 'var(--mat-sys-on-surface)', 193 | font: 'var(--mat-sys-title-large)', 194 | padding: '0 0 0.6em 0', 195 | } 196 | }, 197 | subtitle: { 198 | align: 'left', 199 | style: { 200 | color: 'var(--mat-sys-on-surface-variant)', 201 | font: 'var(--mat-sys-title-medium)', 202 | } 203 | }, 204 | xAxis: xAxisConfig, 205 | yAxis: yAxisConfig, 206 | legend: { 207 | align: 'left', 208 | verticalAlign: 'top', 209 | itemStyle: { 210 | color: 'var(--mat-sys-on-surface)', 211 | font: 'var(--mat-sys-body-large)' 212 | }, 213 | itemHoverStyle: { 214 | color: 'var(--mat-sys-primary)' 215 | }, 216 | backgroundColor: 'var(--mat-sys-surface-container)' 217 | }, 218 | tooltip: { 219 | backgroundColor: 'var(--mat-sys-surface-container)', 220 | borderColor: 'var(--mat-sys-outline)', 221 | borderRadius: 4, 222 | padding: 12, 223 | shadow: { 224 | color: 'var(--mat-sys-shadow)', 225 | offsetX: 2, 226 | offsetY: 2, 227 | opacity: 0.2 228 | }, 229 | style: { 230 | color: 'var(--mat-sys-on-surface)', 231 | font: 'var(--mat-sys-body-medium)', 232 | fontSize: '14px' 233 | }, 234 | useHTML: true, 235 | headerFormat: tooltipHeaderFormat, 236 | pointFormat: tooltipPointFormat, 237 | footerFormat: tooltipFooterFormat 238 | }, 239 | plotOptions: { 240 | column: { 241 | borderRadius: 4, 242 | borderWidth: 0 243 | }, 244 | pie: { 245 | borderWidth: 0, 246 | borderRadius: 4, 247 | dataLabels: { 248 | style: { 249 | font: 'var(--mat-sys-label-large)', 250 | color: 'var(--mat-sys-on-surface)', 251 | fontSize: '14px', 252 | opacity: 0.87, 253 | fontWeight: 'var(--mat-sys-label-large-weight)', 254 | textOutline: 'none', 255 | }, 256 | distance: 20, 257 | connectorWidth: 1, 258 | connectorColor: 'var(--mat-sys-outline-variant)' 259 | } 260 | } 261 | }, 262 | accessibility: { 263 | announceNewData: { 264 | enabled: true 265 | }, 266 | description: 'Chart showing data visualization' 267 | }, 268 | navigation: { 269 | buttonOptions: { 270 | theme: { 271 | fill: 'var(--mat-sys-surface-container)', 272 | stroke: 'var(--mat-sys-outline)', 273 | states: { 274 | hover: { 275 | fill: 'var(--mat-sys-surface-container-high)', 276 | style: { 277 | color: 'var(--mat-sys-on-surface)' 278 | } 279 | }, 280 | select: { 281 | fill: 'var(--mat-sys-surface-container-highest)', 282 | style: { 283 | color: 'var(--mat-sys-on-surface)' 284 | } 285 | } 286 | }, 287 | } 288 | } as Highcharts.NavigationButtonOptions, 289 | menuStyle: { 290 | background: 'var(--mat-sys-surface-container)', 291 | color: 'var(--mat-sys-on-surface)', 292 | border: '0px solid var(--mat-sys-outline)', 293 | borderRadius: 4, 294 | padding: '8px 0', 295 | boxShadow: '0 2px 4px rgba(0,0,0,0.2)', 296 | zIndex: 1000, 297 | }, 298 | menuItemHoverStyle: { 299 | background: 'var(--mat-sys-surface-container-highest)', 300 | color: 'var(--mat-sys-on-surface)', 301 | cursor: 'pointer', 302 | transition: 'background 200ms cubic-bezier(0.4, 0, 0.2, 1)' 303 | }, 304 | menuItemStyle: { 305 | color: 'var(--mat-sys-on-surface)', 306 | fontSize: '14px', 307 | padding: '8px 16px', 308 | fontFamily: 'var(--mat-sys-body-large-font)', 309 | fontWeight: 'var(--mat-sys-body-large-weight)', 310 | transition: 'background 200ms cubic-bezier(0.4, 0, 0.2, 1)', 311 | } 312 | }, 313 | credits: { 314 | enabled: false 315 | }, 316 | exporting: { 317 | enabled: false, 318 | buttons: { 319 | contextButton: { 320 | symbol: 'menu', 321 | symbolStroke: 'var(--mat-sys-on-surface)', 322 | symbolStrokeWidth: 2, 323 | theme: { 324 | fill: 'var(--mat-sys-surface-container)', 325 | stroke: '0px var(--mat-sys-outline)' 326 | }, 327 | } 328 | }, 329 | chartOptions: { 330 | plotOptions: { 331 | series: { 332 | dataLabels: { 333 | enabled: true, 334 | style: { 335 | fontSize: '14px', 336 | fontWeight: 'normal', 337 | textOutline: 'none' 338 | } 339 | } 340 | } 341 | } 342 | } 343 | } 344 | }; 345 | Highcharts.setOptions(theme); 346 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitHub Usage Report Viewer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule) 7 | .catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /src/material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { MatPaginatorModule } from '@angular/material/paginator'; 4 | import { MatSortModule } from '@angular/material/sort'; 5 | import { MatTableModule } from '@angular/material/table'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | import { MatFormFieldModule } from '@angular/material/form-field'; 8 | import { MatButtonModule } from '@angular/material/button'; 9 | import { MatIconModule } from '@angular/material/icon'; 10 | import { MatDatepickerModule } from '@angular/material/datepicker'; 11 | import { MatNativeDateModule } from '@angular/material/core'; 12 | import { MatSelectModule } from '@angular/material/select'; 13 | import { MatAutocompleteModule } from '@angular/material/autocomplete'; 14 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 15 | import { MatButtonToggleModule } from '@angular/material/button-toggle'; 16 | import { FormsModule } from '@angular/forms'; 17 | import { MatDialogModule } from '@angular/material/dialog'; 18 | import { MatChipsModule } from '@angular/material/chips'; 19 | import { MatTabsModule } from '@angular/material/tabs'; 20 | import { MatListModule } from '@angular/material/list'; 21 | import { MatTooltipModule } from '@angular/material/tooltip'; 22 | import { MatMenuModule } from '@angular/material/menu'; 23 | 24 | @NgModule({ 25 | exports: [ 26 | MatFormFieldModule, 27 | MatInputModule, 28 | MatTableModule, 29 | MatSortModule, 30 | MatPaginatorModule, 31 | MatButtonModule, 32 | MatTableModule, 33 | MatPaginatorModule, 34 | MatSortModule, 35 | MatIconModule, 36 | MatDatepickerModule, 37 | MatNativeDateModule, 38 | MatSelectModule, 39 | MatAutocompleteModule, 40 | MatProgressBarModule, 41 | MatButtonToggleModule, 42 | MatInputModule, 43 | FormsModule, 44 | MatButtonModule, 45 | MatDialogModule, 46 | MatChipsModule, 47 | MatTabsModule, 48 | MatListModule, 49 | MatTooltipModule, 50 | MatMenuModule 51 | ] 52 | }) 53 | export class MaterialModule { } 54 | 55 | 56 | /** Copyright 2021 Google LLC. All Rights Reserved. 57 | Use of this source code is governed by an MIT-style license that 58 | can be found in the LICENSE file at http://angular.io/license */ -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------