├── .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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
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 |
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 |
34 | {{column.header}}
35 |
36 |
37 | {{column.cell(row)}}
38 |
42 |
43 | {{ column.footer ? column.footer() : '' }}
44 |
45 | }
46 |
47 |
48 |
49 |
50 |
51 | No data matching the filter "{{input.value}}"
52 |
53 |
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 |
22 | {{column.header}}
23 |
24 |
25 | {{column.cell(row)}}
26 |
27 | {{ column.footer ? column.footer() : '' }}
28 |
29 | }
30 |
31 |
32 |
33 |
34 |
35 | No data matching the filter "{{input.value}}"
36 |
37 |
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 |
14 | {{column.header}}
15 |
16 |
17 | {{column.cell(row)}}
18 |
19 | {{ column.footer ? column.footer() : '' }}
20 |
21 | }
22 |
23 |
24 |
25 |
26 |
27 | No data matching the filter "{{input.value}}"
28 |
29 |
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 |
11 |
12 |
13 | Close
14 | {{'Switch to ' + (data.isEnterprise ? 'Organization' : 'Enterprise')}}
15 | Navigate
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 |
3 | upload_file
4 | {{ text }}
5 |
6 |
7 |
8 | Metered billing usage report
9 |
10 |
11 | Copilot premium requests usage report
12 |
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 |
11 | {{column.header}}
12 |
13 |
14 | {{column.cell(row)}}
15 |
16 | {{ column.footer ? column.footer() : '' }}
17 |
18 | }
19 |
20 |
21 |
22 |
23 |
24 |
25 | No data matching the filter "{{input.value}}"
26 |
27 |
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 |
12 |
13 |
14 | = 99" mode="indeterminate">
15 |
16 |
17 |
Premium Requests
18 |
19 |
20 |
63 |
64 | 0">
65 |
66 | commit
67 | Actions
68 |
69 |
70 |
71 |
72 |
73 |
74 | 0">
75 |
76 | backup
77 | Shared Storage
78 |
79 |
80 |
81 |
82 |
83 |
84 | 0">
85 |
86 | computer
87 | Codespaces
88 |
89 |
90 |
91 |
92 |
93 |
94 | 0">
95 |
96 |
97 | Copilot
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
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 | '{point.key} ';
5 |
6 | const tooltipPointFormat =
7 | '● {series.name}: {point.y} ';
8 |
9 | const tooltipFooterFormat = '
';
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 | '{point.key} ';
5 |
6 | const tooltipPointFormat =
7 | '● {series.name}: {point.y} ';
8 |
9 | const tooltipFooterFormat = '
';
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 |
--------------------------------------------------------------------------------